Credibility pass: one voice, no scars
A three-reviewer sweep for tells of piecemeal machine generation, every finding verified against the code before touching it. No behavior changes; the full suite passes unchanged (plus two strengthened pins). Engine: removed four void-silenced fossils (a parseEdgeKey call voided where it stood, stoneEffect's ignored cardId parameter, the actualTarget remnant in doCast, a voided loop variable in shadow upkeep); fixed the initialize-then-overwrite narration in spawnCreature; replaced a filter(() => false) no-op; waterwall now rides waveFromEdge instead of carrying its own verbatim copy (and the single-caller washBack wrapper went with it); blind wall-bumps and LOS blockers each collapsed to one implementation; the wand-id list and the "permanent" duration sentinel became named constants; the ambush number local no longer shadows the imported numberValue function; assorted reviewer-aimed phrasings rewritten as the constraints they guard. Server/deploy: the protocol header now documents all eleven message types; dropped an eslint pragma with no eslint, a test script with no tests, and an rsync exclude anchored at a path that never existed (the real data/ dir now excluded); the Caddy vhost has one source of truth; stale "pending DNS" note removed — the record resolves. Web: ~90 lines of CSS swallowed verbatim into a mobile media query deduplicated; the reduced-motion guard on the board now actually stops the marked-cell pulse; one shared color module replaces two drifted palettes; an orphaned doc comment rejoined its function. Tests: the ten-times-pasted helper block became test/helpers.ts; wave-numbered files renamed for the behaviors they pin; deliberation comments and void-ed corpses of unwritten assertions deleted; silent seed-dependent early-returns now fail loudly; one assertion that compared a value to itself now pins the home-translation it meant to; the stored-log single-number command form gained the explicit compatibility test it deserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f62fcf2510
commit
695307daa8
+55
-88
@@ -133,6 +133,11 @@ export interface SquareContent {
|
||||
createdBy: PlayerId;
|
||||
}
|
||||
|
||||
const WAND_CARD_IDS = ["blaster-wand", "shift-wand", "sticky-wand", "warp-wand"] as const;
|
||||
|
||||
/** Duration meaning "for the rest of the game" ("This card is permanent."). */
|
||||
const PERMANENT_TURNS = 1_000_000_000;
|
||||
|
||||
/** Which square contents block line of sight. */
|
||||
export const LOS_BLOCKING_CONTENT: Record<SquareContent["kind"], boolean> = {
|
||||
stone: true, thornbush: true, rosebush: true, dust: true, slime: true,
|
||||
@@ -265,17 +270,21 @@ export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: strin
|
||||
return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId));
|
||||
}
|
||||
|
||||
/** LOS including square-filling blockers (stone, thornbushes). */
|
||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||
/** Square-filling sight blockers: stone, bushes — and the BIG MAN, whom no spell passes. */
|
||||
export function losBlockers(state: GameState): Record<string, true> {
|
||||
const blockers: Record<string, true> = {};
|
||||
for (const [key, content] of Object.entries(state.squareContents)) {
|
||||
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
||||
}
|
||||
// BIG MAN: you cannot cast spells past him.
|
||||
for (const p of state.players) {
|
||||
if (p.alive && sustainedOn(state, p.id, "big-man").length > 0) blockers[cellKey(p.position)] = true;
|
||||
}
|
||||
return hasLineOfSight(boardView(state), from, to, blockers);
|
||||
return blockers;
|
||||
}
|
||||
|
||||
/** LOS including square-filling blockers. */
|
||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||
return hasLineOfSight(boardView(state), from, to, losBlockers(state));
|
||||
}
|
||||
|
||||
/** Parse an edge key back into its north/west cell and side. */
|
||||
@@ -328,9 +337,7 @@ function perceivedBoard(
|
||||
}
|
||||
// Untested: only roll if this sight line would actually cross it.
|
||||
if (sightLine) {
|
||||
const { cell, side } = parseEdgeKey(key);
|
||||
const test = { ...view, edges: { [key]: "wall" as const } };
|
||||
void cell; void side;
|
||||
const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to);
|
||||
if (crossesIt) {
|
||||
if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall";
|
||||
@@ -354,10 +361,7 @@ function casterLos(
|
||||
events: GameEvent[] = [],
|
||||
): boolean {
|
||||
const board = perceivedBoard(state, events, caster.id, { from, to });
|
||||
const blockers: Record<string, true> = {};
|
||||
for (const [key, content] of Object.entries(state.squareContents)) {
|
||||
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
||||
}
|
||||
const blockers = losBlockers(state);
|
||||
if (hasLineOfSight(board, from, to, blockers)) return true;
|
||||
if (!displays(caster, "visionstone")) return false;
|
||||
for (const key of Object.keys(board.edges)) {
|
||||
@@ -568,7 +572,7 @@ export type Command =
|
||||
instanceId: string;
|
||||
/** Number cards powering the cast (two allowed when an ADD is attached). */
|
||||
numberInstanceIds?: string[];
|
||||
/** Legacy single-number field; merged into numberInstanceIds. */
|
||||
/** Single-number form still present in stored command logs; folded into numberInstanceIds. */
|
||||
numberInstanceId?: string;
|
||||
/** AMPLIFY cards attached (each doubles power/duration). */
|
||||
amplifyInstanceIds?: string[];
|
||||
@@ -1074,7 +1078,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
||||
// Effectively permanent: broken by the caster attacking the target.
|
||||
attachSustained(state, events, "buddy", caster.id, target.id, 1_000_000_000);
|
||||
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
@@ -1193,29 +1197,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
|
||||
// The two sides of the edge, and the push directions away from it.
|
||||
const a = cell;
|
||||
const b = neighbor(cell, side);
|
||||
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
|
||||
const pushes: { start: Cell; dir: Side }[] = [
|
||||
{ start: a, dir: away(side) },
|
||||
{ start: b, dir: side },
|
||||
];
|
||||
for (const { start, dir } of pushes) {
|
||||
// Players on the two cells extending away from the edge on this side.
|
||||
let probe = start;
|
||||
for (let dist = 0; dist < 2; dist++) {
|
||||
for (const p of state.players) {
|
||||
if (!p.alive || cellKey(p.position) !== cellKey(probe)) continue;
|
||||
washBack(state, events, p, dir);
|
||||
}
|
||||
for (const c of [...state.creatures]) {
|
||||
if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) {
|
||||
destroyCreature(state, events, c, "waterwall");
|
||||
}
|
||||
}
|
||||
probe = neighbor(probe, dir);
|
||||
}
|
||||
}
|
||||
waveFromEdge(state, events, cell, side, 2, "waterwall");
|
||||
checkVictory(state, events);
|
||||
return null;
|
||||
},
|
||||
@@ -1303,11 +1285,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
},
|
||||
},
|
||||
// --- Magic stones ---------------------------------------------------------
|
||||
bloodstone: stoneEffect("bloodstone"),
|
||||
powerstone: stoneEffect("powerstone"),
|
||||
shadowstone: stoneEffect("shadowstone"),
|
||||
soulstone: stoneEffect("soulstone"),
|
||||
speedstone: stoneEffect("speedstone", (state, _events, caster) => {
|
||||
bloodstone: stoneEffect(),
|
||||
powerstone: stoneEffect(),
|
||||
shadowstone: stoneEffect(),
|
||||
soulstone: stoneEffect(),
|
||||
speedstone: stoneEffect((state, _events, caster) => {
|
||||
// "Your movement rate is increased by 1" — starting now, not next turn.
|
||||
// A delta (not a recompute) so number cards already played stay counted.
|
||||
// No bump while SLOW forces 1, or when this turn's movement is already
|
||||
@@ -1318,9 +1300,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
state.turn.movementAllowance += 1;
|
||||
}
|
||||
}),
|
||||
shieldstone: stoneEffect("shieldstone"),
|
||||
visionstone: stoneEffect("visionstone"),
|
||||
brainstone: stoneEffect("brainstone", (state, events, caster) => {
|
||||
shieldstone: stoneEffect(),
|
||||
visionstone: stoneEffect(),
|
||||
brainstone: stoneEffect((state, events, caster) => {
|
||||
// "Draw two more cards, now."
|
||||
const drawn: CardInstance[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
@@ -1339,7 +1321,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// "This is permanent. Once SLOW DEATH is on, it can't be turned off."
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
|
||||
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
||||
},
|
||||
},
|
||||
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
|
||||
@@ -1470,7 +1452,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster) => {
|
||||
if (state.players.filter((p) => p.alive).length <= 2) return "not applicable in a 2-player game";
|
||||
attachSustained(state, events, "lifesaver", caster.id, caster.id, 1_000_000_000);
|
||||
attachSustained(state, events, "lifesaver", caster.id, caster.id, PERMANENT_TURNS);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
@@ -1543,7 +1525,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
const wanted = cmd.params?.cardId;
|
||||
if (!wanted) return "name the card to retrieve";
|
||||
if (["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"].includes(wanted)) {
|
||||
if ((WAND_CARD_IDS as readonly string[]).includes(wanted)) {
|
||||
return "deja-vu cannot retrieve a magic wand";
|
||||
}
|
||||
for (let i = state.discard.length - 1; i >= 0; i--) {
|
||||
@@ -1680,7 +1662,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
return "both squares must hold an item";
|
||||
}
|
||||
if (itemsA.length > 0 || itemsB.length > 0) {
|
||||
if (itemsA.length > 0) state.groundObjects[kb] = [...itemsB.filter(() => false), ...itemsA];
|
||||
if (itemsA.length > 0) state.groundObjects[kb] = [...itemsA];
|
||||
else delete state.groundObjects[kb];
|
||||
if (itemsB.length > 0) state.groundObjects[ka] = [...itemsB];
|
||||
else delete state.groundObjects[ka];
|
||||
@@ -1773,7 +1755,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// "1/2 point of damage for every space moved. This spell is permanent."
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
|
||||
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
||||
},
|
||||
},
|
||||
disease: {
|
||||
@@ -2040,7 +2022,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position);
|
||||
if (!hasTreasureOut) return; // "ends if both treasures are being carried"
|
||||
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
|
||||
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
||||
},
|
||||
},
|
||||
"big-man": {
|
||||
@@ -2268,7 +2250,7 @@ function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
|
||||
}
|
||||
|
||||
/** A collapsing waterwall wave from an edge: wash players back `range`. */
|
||||
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number): void {
|
||||
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
|
||||
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
|
||||
const pushes: { start: Cell; dir: Side }[] = [
|
||||
{ start: cell, dir: away(side) },
|
||||
@@ -2282,7 +2264,7 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
|
||||
}
|
||||
for (const c of [...state.creatures]) {
|
||||
if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) {
|
||||
destroyCreature(state, events, c, "rushing water");
|
||||
destroyCreature(state, events, c, reason);
|
||||
}
|
||||
}
|
||||
probe = neighbor(probe, dir);
|
||||
@@ -2334,8 +2316,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
|
||||
}
|
||||
|
||||
/** A displayable stone: casting it turns it face-up; its power is passive. */
|
||||
function stoneEffect(cardId: string, onDisplay?: (state: GameState, events: GameEvent[], caster: PlayerState) => void): NeutralEffect {
|
||||
void cardId;
|
||||
function stoneEffect(onDisplay?: (state: GameState, events: GameEvent[], caster: PlayerState) => void): NeutralEffect {
|
||||
return {
|
||||
kind: "neutral",
|
||||
keepInHand: true,
|
||||
@@ -2398,10 +2379,6 @@ function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: S
|
||||
}
|
||||
}
|
||||
|
||||
function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void {
|
||||
washBackN(state, events, p, dir, 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Creatures
|
||||
|
||||
@@ -2439,15 +2416,13 @@ function spawnCreature(
|
||||
damage: 0,
|
||||
maxDamage: stats.maxDamage,
|
||||
movesPerTurn: stats.moves,
|
||||
movementUsed: stats.moves, // no movement on the creation turn's remainder...
|
||||
movementUsed: 0, // "It may move on that turn." (Exp1 sheet)
|
||||
attackUsed: true, // "cannot attack the turn they are created"
|
||||
justCreated: true,
|
||||
wallPassesPerTurn: stats.wallPasses,
|
||||
wallPassUsed: 0,
|
||||
scorchedThisTurn: [],
|
||||
};
|
||||
// "...but may move on that turn." (Exp1 sheet) — movement allowed at once.
|
||||
creature.movementUsed = 0;
|
||||
state.creatures.push(creature);
|
||||
events.push({ type: "creatureCreated", creatureId: creature.id, kind, controller: controllerId, at });
|
||||
return creature;
|
||||
@@ -3157,6 +3132,13 @@ function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null {
|
||||
|
||||
// --- Movement ---------------------------------------------------------------
|
||||
|
||||
/** A blind wizard's wasted lurch into a wall still costs a movement point. */
|
||||
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
|
||||
state.turn.movementUsed++;
|
||||
events.push({ type: "moveBumped", player: p.id, direction });
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
@@ -3188,11 +3170,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
const key = edgeKey(p.position, direction);
|
||||
if (state.illusionWalls[key] &&
|
||||
illusionBelief(state, events, p.id, key) === "believes") {
|
||||
if (isBlinded(state, p)) {
|
||||
state.turn.movementUsed++;
|
||||
events.push({ type: "moveBumped", player: p.id, direction });
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||
return err("blocked by wall");
|
||||
}
|
||||
}
|
||||
@@ -3209,11 +3187,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
const edge = view.edges[key] ?? "open";
|
||||
const dest = neighbor(p.position, direction);
|
||||
if (!view.cells[cellKey(dest)]) {
|
||||
if (isBlinded(state, p)) {
|
||||
state.turn.movementUsed++;
|
||||
events.push({ type: "moveBumped", player: p.id, direction });
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||
return err("blocked");
|
||||
}
|
||||
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
|
||||
@@ -3235,10 +3209,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
p.position = dest;
|
||||
via = "passWall";
|
||||
} else if (isBlinded(state, p)) {
|
||||
// Blind bump: the wasted lurch costs a movement point.
|
||||
state.turn.movementUsed++;
|
||||
events.push({ type: "moveBumped", player: p.id, direction });
|
||||
return { ok: true, state, events };
|
||||
return blindBump(state, events, p, direction);
|
||||
} else {
|
||||
return err(`blocked by ${target.by}`);
|
||||
}
|
||||
@@ -3739,7 +3710,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
|
||||
// Magic wands: charged on first use by the number card(s) played; one
|
||||
// charge per use, one use per turn; discarded when the last charge goes.
|
||||
const WANDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]);
|
||||
const WANDS = new Set<string>(WAND_CARD_IDS);
|
||||
const isWand = WANDS.has(inHand.cardId);
|
||||
if (isWand && state.turn.wandsUsed.includes(inHand.instanceId)) {
|
||||
return err("any wand operates a maximum of once per turn");
|
||||
@@ -3877,7 +3848,6 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
|
||||
// go intended distance" — if the die disagrees with the true direction,
|
||||
// the spell hits whoever lies that way, or dissipates.
|
||||
let actualTarget = target;
|
||||
if (isBlinded(state, caster) &&
|
||||
cellKey(target.position) !== cellKey(caster.position)) {
|
||||
const dx = target.position.x - caster.position.x;
|
||||
@@ -3904,7 +3874,6 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
rolledDirection: rolled, newTarget: along?.id ?? null,
|
||||
}];
|
||||
if (!along) return { ok: true, state, events: missEvents }; // dissipates
|
||||
actualTarget = along;
|
||||
state.stack = {
|
||||
attackerId: caster.id,
|
||||
defenderId: along.id,
|
||||
@@ -3926,7 +3895,6 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
return { ok: true, state, events: missEvents };
|
||||
}
|
||||
}
|
||||
void actualTarget;
|
||||
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||
@@ -3994,7 +3962,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
}
|
||||
const result = (effect as NeutralEffect).resolve(state, events, caster, cmd, mods.magnitude);
|
||||
if (result) return err(result); // unreachable after preview
|
||||
if (result) return err(result); // non-null here means resolve and preview disagree
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
@@ -4107,7 +4075,7 @@ function checkAmbushes(
|
||||
|
||||
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
|
||||
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
|
||||
const numberValue = ambush.numbers.length > 0
|
||||
const numberTotal = ambush.numbers.length > 0
|
||||
? ambush.numbers.reduce((t, c) => t + (cardDef(c.cardId).value ?? 0), 0)
|
||||
: null;
|
||||
events.push({
|
||||
@@ -4118,7 +4086,7 @@ function checkAmbushes(
|
||||
attackerId: owner.id,
|
||||
defenderId: actor.id,
|
||||
attackCard: ambush.spell,
|
||||
numberValue,
|
||||
numberValue: numberTotal,
|
||||
amplifyFactor: 1,
|
||||
extendFactor: 1,
|
||||
powerAttackPoints: 0,
|
||||
@@ -4129,7 +4097,7 @@ function checkAmbushes(
|
||||
};
|
||||
events.push({
|
||||
type: "spellCast", caster: owner.id, card: ambush.spell, cardId: ambush.spell.cardId,
|
||||
numberCards: ambush.numbers, numberValue,
|
||||
numberCards: ambush.numbers, numberValue: numberTotal,
|
||||
from: owner.position, target: actor.id, targetCell: actor.position,
|
||||
});
|
||||
return; // one ambush per check; others may spring on later steps
|
||||
@@ -4152,7 +4120,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
|
||||
if (playerId === stack.defenderId) {
|
||||
if (card.cardId === "absorb-spell") {
|
||||
if (stack.kind !== "spell") return err("absorb spell only works against spells");
|
||||
if (stack.attackCard && ["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"].includes(stack.attackCard.cardId)) {
|
||||
if (stack.attackCard && (WAND_CARD_IDS as readonly string[]).includes(stack.attackCard.cardId)) {
|
||||
return err("Absorb Spell has no effect on magic wands");
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
@@ -4225,7 +4193,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
|
||||
}
|
||||
|
||||
if (playerId === stack.attackerId) {
|
||||
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction (for now)");
|
||||
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction");
|
||||
const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified);
|
||||
if (!targetCounter) return err("no counteraction to nullify");
|
||||
takeFromHand(player, instanceId);
|
||||
@@ -4601,8 +4569,7 @@ function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
|
||||
* charges are keyed by instance, so they travel automatically).
|
||||
*/
|
||||
const MOVABLE_OBJECT_CARD_IDS = new Set([
|
||||
"dagger", "large-rock", "wizardblade",
|
||||
"blaster-wand", "shift-wand", "sticky-wand", "warp-wand",
|
||||
"dagger", "large-rock", "wizardblade", ...WAND_CARD_IDS,
|
||||
]);
|
||||
|
||||
export function isMovableObject(cardId: string): boolean {
|
||||
@@ -4751,13 +4718,13 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
||||
}
|
||||
// SHADOW upkeep: 1 life per turn, even during lost turns (handled where
|
||||
// turns are skipped too).
|
||||
for (const c of state.creatures.filter((c) => c.kind === "shadow" && c.controllerId === player.id)) {
|
||||
const shadows = state.creatures.filter((c) => c.kind === "shadow" && c.controllerId === player.id).length;
|
||||
for (let i = 0; i < shadows; i++) {
|
||||
player.life -= 1;
|
||||
events.push({ type: "shadowUpkeep", player: player.id, lifeAfter: player.life });
|
||||
if (player.life <= 0) {
|
||||
applyDamage(state, events, player, 0, "shadow upkeep", null); // triggers death path at <=0
|
||||
}
|
||||
void c;
|
||||
}
|
||||
|
||||
// Duration spells expire at the start of their CASTER's turns.
|
||||
|
||||
@@ -157,7 +157,6 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
for (const [key, content] of Object.entries(view.squareContents)) {
|
||||
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
||||
}
|
||||
// BIG MAN: you cannot cast spells past him.
|
||||
for (const p of view.players) {
|
||||
if (p.alive && view.sustained.some((s) => s.cardId === "big-man" && s.targetId === p.id)) {
|
||||
blockers[`${p.position.x},${p.position.y}`] = true;
|
||||
|
||||
Reference in New Issue
Block a user