Expansion wave 1: the creature system and first eight monster cards

Creatures are first-class citizens: TROLL (D4 punches, 6 damage to
kill, regenerates at its creator's turn end), SKELETON (2-point
punches), WRAITH (walks through one wall a turn; its touch deals 2
and steals a random card), FIRE IMP (a stationary turret scorching
anyone in sight once per turn — including its creator — killed only
by Waterbolt or a Waterwall wave), DEMOCRATIC MONSTER (moved three
spaces by EVERY player on their turn, one claw per round), SHADOW (a
second body costing a life point per turn, destroyed by any damage),
and ALTER EGO (a stationary double). Monsters obey their creators,
move on the controller's turn, attack once per turn but never on
their creation turn (summoning IS your attack), refuse to strike
their creators, and vanish when their creator dies. Attacks can
target creatures directly (no counteraction window — monsters don't
counter); Dispel Creation un-creates them. Plus MEGA-MONSTER (double
a monster's toughness or speed), ADRENALINE (two attacks a turn),
MAD DASH, and LIFESAVER. Expansion Set #2 confirmed by Eric as a
5e-era product — marked historical-only in the data; the 6e game is
exactly base + Expansion #1 (200 cards, all verified). Lobby gains an
"include Expansion Set #1" toggle; the client renders creatures as
diamond tokens with select-move-attack interaction. 95 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 22:41:54 -04:00
co-authored by Claude Fable 5
parent e475253fb0
commit df0675c735
10 changed files with 872 additions and 14 deletions
+427 -1
View File
@@ -81,6 +81,28 @@ export interface SustainedEffect {
edge?: string;
}
/** A summoned creature (or SHADOW/ALTER EGO double). */
export interface CreatureState {
id: string;
kind: "troll" | "skeleton" | "wraith" | "fire-imp" | "democratic-monster" | "shadow" | "alter-ego";
controllerId: PlayerId;
position: Cell;
/** Damage taken so far. */
damage: number;
/** Damage needed to destroy (Infinity: immune to ordinary damage). */
maxDamage: number;
movesPerTurn: number;
movementUsed: number;
attackUsed: boolean;
/** No attacks on the turn it was created. */
justCreated: boolean;
/** WRAITH: may pass through one wall/object per turn. */
wallPassesPerTurn: number;
wallPassUsed: number;
/** FIRE IMP: players already scorched this game-turn. */
scorchedThisTurn: PlayerId[];
}
/** Something occupying a whole square (FILL SQUARE WITH STONE, THORNBUSH). */
export interface SquareContent {
kind: "stone" | "thornbush";
@@ -97,6 +119,8 @@ export interface TurnState {
movementUsed: number;
numberPlayedForMovement: boolean;
attackUsed: boolean;
/** ADRENALINE's second attack, once spent. */
secondAttackUsed: boolean;
/** SLOW: "his attacks [reduce] to every other turn". */
attackForbidden: boolean;
actionsEnded: boolean;
@@ -153,6 +177,8 @@ export interface GameState {
lastSpellUsed: Record<PlayerId, string>;
/** ILLUSION WALLs by edge key: real only for those who believe. */
illusionWalls: Record<string, { createdBy: PlayerId; belief: Record<PlayerId, "believes" | "seesThrough"> }>;
creatures: CreatureState[];
nextCreatureId: number;
players: PlayerState[];
treasures: TreasureState[];
sustained: SustainedEffect[];
@@ -366,6 +392,16 @@ export type GameEvent =
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell }
| { type: "creatureCreated"; creatureId: string; kind: CreatureState["kind"]; controller: PlayerId; at: Cell }
| { type: "creatureMoved"; creatureId: string; from: Cell; to: Cell; direction: Side; by: PlayerId }
| { type: "creatureAttacked"; creatureId: string; target: PlayerId | string; dieRoll: number | null }
| { type: "creatureTouched"; creatureId: string; player: PlayerId }
| { type: "creatureDamaged"; creatureId: string; amount: number; source: string; damageTotal: number }
| { type: "creatureDestroyed"; creatureId: string; kind: CreatureState["kind"]; by: string }
| { type: "trollRegenerated"; creatureId: string }
| { type: "shadowUpkeep"; player: PlayerId; lifeAfter: number }
| { type: "impScorches"; creatureId: string; player: PlayerId }
| { type: "monsterBoosted"; creatureId: string; boost: "life" | "movement" }
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
| { type: "doorsRelocked"; count: number }
@@ -398,6 +434,7 @@ export function redactEvent(event: GameEvent, viewer: PlayerId): GameEvent | nul
export type CastTarget =
| { kind: "player"; playerId: PlayerId }
| { kind: "creature"; creatureId: string }
| { kind: "edge"; cell: Cell; side: Side }
| { kind: "cell"; cell: Cell };
@@ -405,6 +442,8 @@ export type Command =
| { type: "move"; direction: Side }
| { type: "playNumberForMovement"; instanceId: string }
| { type: "punch"; targetId: PlayerId }
| { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureAttack"; creatureId: string; targetId: string }
| {
type: "cast";
instanceId: string;
@@ -1045,6 +1084,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
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);
}
}
@@ -1075,6 +1119,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
const creature = creatureAt(state, cmd.target.cell);
if (creature) {
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
destroyCreature(state, events, creature, "dispel creation");
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
return null;
}
const content = state.squareContents[key];
if (!content) return "nothing created there";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
@@ -1231,6 +1282,64 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
return null;
},
},
// --- Expansion #1: creatures ---------------------------------------------
troll: summonEffect("troll"),
skeleton: summonEffect("skeleton"),
wraith: summonEffect("wraith"),
"fire-imp": summonEffect("fire-imp"),
"democratic-monster": summonEffect("democratic-monster"),
shadow: summonEffect("shadow"),
"alter-ego": {
kind: "neutral",
// "Create a stationary double of yourself in the square you now occupy."
resolve: (state, events, caster) => {
if (creatureAt(state, caster.position)) return "a creature is already here";
spawnCreature(state, events, "alter-ego", caster.id, caster.position);
return null;
},
},
"mega-monster": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "creature") return "mega-monster targets a monster";
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return "no such monster";
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
if (!gameLos(state, caster.position, creature.position)) return "no line of sight";
const boost = cmd.params?.cardId === "movement" ? "movement" : "life";
if (boost === "movement") creature.movesPerTurn *= 2;
else creature.maxDamage *= 2;
events.push({ type: "monsterBoosted", creatureId: creature.id, boost });
return null;
},
},
adrenaline: {
kind: "neutral",
// "Allows two attacks in one turn. Duration equals NUMBER card played."
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "adrenaline", caster.id, caster.id, magnitude.duration);
return null;
},
},
"mad-dash": {
kind: "neutral",
// "Doubles your movement (including NUMBER cards and other add-ons) for
// one turn. You cannot carry treasures while exerting yourself."
resolve: (state, events, caster) => {
if (caster.carriedTreasureId) return "you cannot mad-dash while carrying a treasure";
state.turn.movementAllowance *= 2;
events.push({ type: "lifeTraded", player: caster.id, points: 0, newAllowance: state.turn.movementAllowance });
return null;
},
},
lifesaver: {
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);
return null;
},
},
"reuse-spell": {
kind: "neutral",
// "You may retrieve any spell you use immediately after you use it (but
@@ -1254,6 +1363,31 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
},
};
/** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */
function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
return {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
// Summons are printed ATTACK cards: they respect attack rules and
// consume the turn's attack ("creating a monster counts as your
// attack ... on the turn he is created"), but open no counteraction
// window — nobody is attacked yet.
const pre = attackPreconditions(state);
if (pre) return pre;
if (!cmd.target || cmd.target.kind !== "cell") return "choose a square in sight for the creature";
const at = cmd.target.cell;
const view = boardView(state);
if (!view.cells[cellKey(at)]) return "off the board";
if (state.squareContents[cellKey(at)]) return "that square is blocked";
if (creatureAt(state, at)) return "a creature is already there";
if (!casterLos(state, caster, caster.position, at, events)) return "no line of sight";
state.turn.attackUsed = true;
spawnCreature(state, events, kind, caster.id, at);
return null;
},
};
}
/** 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;
@@ -1319,6 +1453,209 @@ function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Si
}
}
// ---------------------------------------------------------------------------
// Creatures
const CREATURE_STATS: Record<CreatureState["kind"], { maxDamage: number; moves: number; wallPasses: number }> = {
troll: { maxDamage: 6, moves: 3, wallPasses: 0 },
skeleton: { maxDamage: 4, moves: 3, wallPasses: 0 },
wraith: { maxDamage: 4, moves: 3, wallPasses: 1 },
"fire-imp": { maxDamage: Infinity, moves: 0, wallPasses: 0 }, // only water kills it
"democratic-monster": { maxDamage: 5, moves: 3, wallPasses: 0 },
shadow: { maxDamage: 1, moves: 3, wallPasses: 0 }, // "any damage at all destroys it"
"alter-ego": { maxDamage: 1, moves: 0, wallPasses: 0 },
};
function creatureById(state: GameState, id: string): CreatureState | undefined {
return state.creatures.find((c) => c.id === id);
}
export function creatureAt(state: GameState, cell: Cell): CreatureState | undefined {
return state.creatures.find((c) => cellKey(c.position) === cellKey(cell));
}
function spawnCreature(
state: GameState,
events: GameEvent[],
kind: CreatureState["kind"],
controllerId: PlayerId,
at: Cell,
): CreatureState {
const stats = CREATURE_STATS[kind];
const creature: CreatureState = {
id: `creature-${state.nextCreatureId++}`,
kind,
controllerId,
position: at,
damage: 0,
maxDamage: stats.maxDamage,
movesPerTurn: stats.moves,
movementUsed: stats.moves, // no movement on the creation turn's remainder...
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;
}
function damageCreature(
state: GameState,
events: GameEvent[],
creature: CreatureState,
amount: number,
source: string,
): void {
creature.damage += amount;
events.push({ type: "creatureDamaged", creatureId: creature.id, amount, source, damageTotal: creature.damage });
if (creature.damage >= creature.maxDamage) {
destroyCreature(state, events, creature, source);
}
}
function destroyCreature(state: GameState, events: GameEvent[], creature: CreatureState, by: string): void {
state.creatures = state.creatures.filter((c) => c.id !== creature.id);
events.push({ type: "creatureDestroyed", creatureId: creature.id, kind: creature.kind, by });
}
/** FIRE IMP: scorch any player in its LOS, once per player per game-turn. */
function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId): void {
for (const imp of state.creatures.filter((c) => c.kind === "fire-imp")) {
for (const p of state.players) {
if (!p.alive) continue;
if (onlyPlayer && p.id !== onlyPlayer) continue;
if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn
if (imp.scorchedThisTurn.includes(p.id)) continue;
// "Imp cannot see through a FIREWALL!" — gameLos already blocks on
// firewall edges and terrain.
if (!gameLos(state, imp.position, p.position)) continue;
imp.scorchedThisTurn.push(p.id);
events.push({ type: "impScorches", creatureId: imp.id, player: p.id });
applyDamage(state, events, p, 2, "fire imp", null);
}
}
checkVictory(state, events);
}
function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const active = activePlayer(state);
const creature = creatureById(state, creatureId);
if (!creature) return err("no such creature");
// The DEMOCRATIC MONSTER is moved by every player; others obey their creator.
if (creature.kind !== "democratic-monster" && creature.controllerId !== active.id) {
return err("that creature does not obey you");
}
if (creature.movesPerTurn === 0) return err("that creature cannot move");
if (creature.movementUsed >= creature.movesPerTurn) return err("no creature movement left");
const events: GameEvent[] = [];
const view = boardView(state);
const from = creature.position;
const target = stepTarget(view, creature.position, direction);
if (target.kind === "blocked") {
// WRAITH: "can move ... through 1 wall or object per turn."
const dest = neighbor(creature.position, direction);
if (
creature.wallPassesPerTurn > creature.wallPassUsed &&
view.cells[cellKey(dest)] &&
state.squareContents[cellKey(dest)]?.kind !== "stone"
) {
creature.wallPassUsed++;
creature.position = dest;
} else {
return err(`blocked by ${target.by}`);
}
} else {
const content = state.squareContents[cellKey(target.to)];
if (content?.kind === "stone") return err("that square is solid stone");
creature.position = target.to;
}
creature.movementUsed++;
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
// Touch effects on entering a player's square.
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, player: p.id });
applyDamage(state, events, p, 2, "wraith's touch", null);
if (p.alive && p.hand.length > 0) {
const [idx, rngNext] = nextInt(state.rng, p.hand.length);
state.rng = rngNext;
const [card] = p.hand.splice(idx, 1);
p.displayed = p.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
events.push({ type: "cardsDiscarded", player: p.id, cards: [card!] });
}
}
if (creature.kind === "democratic-monster" && !creature.attackUsed) {
creature.attackUsed = true; // "may attack only one player per round of turns"
events.push({ type: "creatureTouched", creatureId: creature.id, player: p.id });
applyDamage(state, events, p, 2, "clawing monster", null);
}
}
checkVictory(state, events);
return { ok: true, state, events };
}
function doCreatureAttack(prev: GameState, creatureId: string, targetId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.round === 1) return err("no combat during the first round of turns");
const state = clone(prev);
const active = activePlayer(state);
const creature = creatureById(state, creatureId);
if (!creature) return err("no such creature");
if (creature.controllerId !== active.id) return err("that creature does not obey you");
if (creature.justCreated) return err("it cannot attack the turn it was created");
if (creature.attackUsed) return err("that creature has already attacked this turn");
if (creature.kind !== "troll" && creature.kind !== "skeleton" && creature.kind !== "shadow") {
return err("that creature attacks on its own, not on command");
}
const events: GameEvent[] = [];
const targetPlayer = state.players.find((p) => p.id === targetId && p.alive);
const targetCreature = creatureById(state, targetId);
if (!targetPlayer && !targetCreature) return err("no such target");
const targetPos = targetPlayer ? targetPlayer.position : targetCreature!.position;
if (cellKey(targetPos) !== cellKey(creature.position)) {
return err("the creature must share its target's square");
}
if (targetPlayer && targetPlayer.id === creature.controllerId) return err("it will not hurt its creator");
creature.attackUsed = true;
let amount: number;
let roll: number | null = null;
if (creature.kind === "troll") {
const [r, rngNext] = rollDie(state.rng);
state.rng = rngNext;
roll = r;
amount = r;
} else if (creature.kind === "skeleton") {
amount = 2;
} else {
amount = 1; // shadow punches like a wizard
}
events.push({ type: "creatureAttacked", creatureId: creature.id, target: targetId, dieRoll: roll });
if (targetPlayer) {
applyDamage(state, events, targetPlayer, amount, `${creature.kind}'s blow`, null, "physical");
} else {
damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`);
}
checkVictory(state, events);
return { ok: true, state, events };
}
/** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */
function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void {
const view = boardView(state);
@@ -1656,6 +1993,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
groundObjects: {},
lastSpellUsed: {},
illusionWalls: {},
creatures: [],
nextCreatureId: 1,
players,
treasures,
sustained: [],
@@ -1669,6 +2008,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
movementUsed: 0,
numberPlayedForMovement: false,
attackUsed: false,
secondAttackUsed: false,
attackForbidden: false,
actionsEnded: false,
},
@@ -1716,6 +2056,8 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
case "move": return doMove(state, command.direction);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId);
case "punch": return doPunch(state, command.targetId);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
case "cast": return doCast(state, command);
case "counteract": return err("nothing to counteract");
case "pass": return err("nothing to pass on");
@@ -1852,6 +2194,9 @@ function doMove(prev: GameState, direction: Side): CommandResult {
checkVictory(state, events);
}
// FIRE IMP: scorches the moment a player enters its line of sight.
impCheck(state, events, p.id);
// THORNBUSH: "his turn ends, he loses his following turn, and he takes one
// point of physical damage from thorns." Mist drifts through unharmed.
if (content?.kind === "thornbush" && p.alive && !misted) {
@@ -1903,7 +2248,14 @@ function attackPreconditions(state: GameState): string | null {
const blocked = requireActionsAvailable(state);
if (blocked) return blocked;
if (state.turn.round === 1) return "no combat during the first round of turns";
if (state.turn.attackUsed) return "you may attack only once per turn";
if (state.turn.attackUsed) {
// ADRENALINE: "Allows two attacks in one turn."
const active = activePlayer(state);
if (sustainedOn(state, active.id, "adrenaline").length > 0 && !state.turn.secondAttackUsed) {
return null;
}
return "you may attack only once per turn";
}
if (state.turn.attackForbidden) return "you are slowed — no attack this turn";
return null;
}
@@ -1964,6 +2316,7 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
}
}
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.stack = {
attackerId: attacker.id,
@@ -2125,6 +2478,39 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.kind === "attack") {
const pre = attackPreconditions(state);
if (pre) return err(pre);
// Attacks may target a creature: damage applies directly (monsters play
// no counteractions).
if (cmd.target?.kind === "creature") {
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return err("no such creature");
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
return err("you must be in the same square");
}
if (effect.requiresLos && !casterLos(state, caster, caster.position, creature.position)) {
return err("no line of sight to the creature");
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: creature.position,
}];
// "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP).
if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") {
destroyCreature(state, events, creature, "waterbolt");
return { ok: true, state, events };
}
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
if (creature.kind === "fire-imp") {
events.push({ type: "creatureDamaged", creatureId: creature.id, amount: 0, source: inHand.cardId, damageTotal: creature.damage });
} else if (dmg > 0) {
damageCreature(state, events, creature, dmg, inHand.cardId);
}
return { ok: true, state, events };
}
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
@@ -2212,6 +2598,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
void actualTarget;
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.stack = {
attackerId: caster.id,
@@ -2589,6 +2976,8 @@ function applyDamage(
events.push({ type: "died", player: target.id, killedBy: attackerId });
events.push({ type: "playerEliminated", player: target.id, reason: "killed" });
state.sustained = state.sustained.filter((s) => s.targetId !== target.id && s.casterId !== target.id);
// "If you die, any monster controlled by you immediately disappears."
state.creatures = state.creatures.filter((c) => c.controllerId !== target.id);
if (target.carriedTreasureId) {
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
@@ -2793,6 +3182,32 @@ function drawOne(state: GameState, events: GameEvent[]): CardInstance | null {
function beginTurnFor(state: GameState, events: GameEvent[], index: number): void {
const player = state.players[index]!;
// Creatures refresh at each game-turn boundary. Democratic monsters refresh
// movement for EVERY player's turn; others for their controller's.
for (const c of state.creatures) {
c.justCreated = false;
c.scorchedThisTurn = [];
if (c.kind === "democratic-monster") {
c.movementUsed = 0;
// its single attack refreshes per ROUND: on the first player's turn
if (index === state.turn.firstIndex) c.attackUsed = false;
} else if (c.controllerId === player.id) {
c.movementUsed = 0;
c.wallPassUsed = 0;
c.attackUsed = false;
}
}
// 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)) {
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.
const surviving: SustainedEffect[] = [];
for (const s of state.sustained) {
@@ -2837,6 +3252,7 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
movementUsed: 0,
numberPlayedForMovement: false,
attackUsed: false,
secondAttackUsed: false,
attackForbidden,
actionsEnded: false,
};
@@ -2872,6 +3288,14 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
applySlowDeathOnDraw(state, events, p, drawn.length);
}
// TROLL: "at the end of each turn of its creator, it gets back one point."
for (const c of state.creatures.filter((c) => c.kind === "troll" && c.controllerId === p.id)) {
if (c.damage > 0) {
c.damage -= 1;
events.push({ type: "trollRegenerated", creatureId: c.id });
}
}
// Doors unlocked this turn relock ("the door will relock behind you").
if (state.openDoorEdges.length > 0) {
events.push({ type: "doorsRelocked", count: state.openDoorEdges.length });
@@ -2905,5 +3329,7 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
beginTurnFor(state, events, next);
events.push({ type: "turnStarted", player: state.players[next]!.id, round: state.turn.round });
// FIRE IMP: "...or if in L.O.S. at the start of a player's turn."
impCheck(state, events, state.players[next]!.id);
return { ok: true, state, events };
}
+3
View File
@@ -7,6 +7,7 @@ import { type CardInstance } from "./cards";
import {
boardView,
type CastStack,
type CreatureState,
type GameState,
type PlayerId,
type SquareContent,
@@ -52,6 +53,7 @@ export interface GameView {
openDoorEdges: string[];
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
knownIllusionEdges: string[];
creatures: CreatureState[];
}
export function viewFor(state: GameState, playerId: PlayerId): GameView {
@@ -103,5 +105,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
doorStates: { ...state.doorStates },
openDoorEdges: [...state.openDoorEdges],
knownIllusionEdges,
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
};
}