diff --git a/packages/engine/data/VERIFICATION.md b/packages/engine/data/VERIFICATION.md index 9612d8d..92b209c 100644 --- a/packages/engine/data/VERIFICATION.md +++ b/packages/engine/data/VERIFICATION.md @@ -60,12 +60,13 @@ which layout was replaced before 7e, board backs. standees + 12 color-keyed treasures. No numbered duration/charge tokens were present (cosmetic-only; digital tracks these natively). -## 📦 If Expansion Set #2 is ever acquired +## 📦 Expansion Set #2 — HISTORICAL ONLY (resolved 2026-08-15) -Owner does not own Exp2; its 85 entries in cards.json keep null quantities. A census -would resolve: quantities, the 85-cards-vs-88-names arithmetic, Bomb's true home, the -Full Shield / Pick Lock / Reflection reprints, and the 16 Artifact cards (no scans of -them exist anywhere online — photograph individually). +The owner confirms the 6th edition had exactly one expansion. Expansion Set #2 was a +5e-era product; its cards never shipped for 6e. The 85 'expansion2' entries in +cards.json are retained as historical reference with null quantities (the deck builder +can never include them) and their 3 'uncertain' flags are permanently moot for this +project. --- diff --git a/packages/engine/data/cards.json b/packages/engine/data/cards.json index ae6bc7b..c0a80d8 100644 --- a/packages/engine/data/cards.json +++ b/packages/engine/data/cards.json @@ -28,7 +28,8 @@ "knownOmissions": [ "7th-edition delta: the 1997 deck grew to 130 cards; the 5 added cards are undocumented.", "BUTT-HEAD and MEGA-MONSTER (official Exp1 list) have no known text anywhere - entries exist with text: null pending card photos." - ] + ], + "expansion2Note": "HISTORICAL ONLY: the 6th edition had exactly ONE expansion (Expansion Set #1). The 'expansion2' entries are 5e-era cards (Expansion Set #2 was boxed for 5th edition and earlier; its Artifacts never appeared in Chessex 6e/7e printings). They are retained as reference data and are never part of the 6e game; the deck builder cannot include them (all quantities are null). Confirmed by the owner, 2026-08-15." }, "cards": [ { diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 3087f87..326e184 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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; /** ILLUSION WALLs by edge key: real only for those who believe. */ illusionWalls: Record }>; + 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 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 } 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 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 }, }; +/** 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 = { + 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): 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): 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 }; } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 4a99edc..503546f 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -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] })), }; } diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts new file mode 100644 index 0000000..721c4e4 --- /dev/null +++ b/packages/engine/test/creatures.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from "vitest"; +import { + applyCommand, + activePlayer, + createGame, + boardView, + creatureAt, + gameLos, + type Command, + type GameState, + type PlayerId, +} from "../src/game"; +import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; +import type { CardInstance } from "../src/cards"; + +function newGame(seed = 42) { + return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] }); +} + +function must(state: GameState, player: PlayerId, command: Command): GameState { + const result = applyCommand(state, player, command); + if (!result.ok) throw new Error(`command failed: ${result.error}`); + return result.state; +} + +function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance { + const p = state.players.find((p) => p.id === playerId)!; + const instance = { instanceId: `${cardId}#${tag}`, cardId }; + p.hand[slot] = instance; + return instance; +} + +function toRound2(state: GameState): GameState { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + return state; +} + +function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } { + const view = boardView(state); + for (const side of SIDES) { + const t = stepTarget(view, of, side); + if (t.kind !== "step") continue; + const key = cellKey(t.to); + if (view.homes.some((h) => cellKey(h) === key)) continue; + if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue; + if (state.players.some((p) => cellKey(p.position) === key)) continue; + return { cell: t.to, side }; + } + throw new Error("no empty neighbor"); +} + +/** Summon a creature next to its creator (round 2+, consumes the attack). */ +function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { + const me = state.players.find((p) => p.id === playerId)!; + const spot = emptyNeighborCell(state, me.position); + const card = giveCard(state, playerId, kind, tag); + const next = must(state, playerId, { + type: "cast", instanceId: card.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + return { state: next, at: spot.cell }; +} + +describe("expansion deck", () => { + it("basic + expansion1 builds the full 200-card game", () => { + const { state } = newGame(); + const total = state.deck.length + state.discard.length + + state.players.reduce((s, p) => s + p.hand.length, 0); + expect(total).toBe(200); + }); +}); + +describe("monsters", () => { + it("summoning uses your attack; the monster moves now but attacks next turn", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "skeleton"); + state = r.state; + const skeleton = creatureAt(state, r.at)!; + expect(skeleton.kind).toBe("skeleton"); + expect(state.turn.attackUsed).toBe(true); + + // It can move on the creation turn... + const view = boardView(state); + for (const side of SIDES) { + if (stepTarget(view, skeleton.position, side).kind === "step") { + state = must(state, me, { type: "moveCreature", creatureId: skeleton.id, direction: side }); + break; + } + } + // ...but cannot attack until next turn. + const other = state.players.find((p) => p.id !== me)!; + other.position = { ...state.creatures[0]!.position }; + const refused = applyCommand(state, me, { + type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: other.id, + }); + expect(refused.ok).toBe(false); + + // Next turn: the skeleton punches for 2. + state = must(state, me, { type: "endTurn", draw: 0 }); + state = must(state, other.id, { type: "endTurn", draw: 0 }); + const victim = state.players.find((p) => p.id !== me)!; + victim.position = { ...state.creatures[0]!.position }; + state = must(state, me, { + type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id, + }); + expect(state.players.find((p) => p.id !== me)!.life).toBe(13); + }); + + it("a monster will not obey the enemy and will not strike its creator", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "skeleton"); + state = r.state; + const id = state.creatures[0]!.id; + const other = state.players.find((p) => p.id !== me)!.id; + state = must(state, me, { type: "endTurn", draw: 0 }); + expect(applyCommand(state, other, { type: "moveCreature", creatureId: id, direction: "N" }).ok).toBe(false); + state = must(state, other, { type: "endTurn", draw: 0 }); + const creator = state.players.find((p) => p.id === me)!; + creator.position = { ...state.creatures[0]!.position }; + expect(applyCommand(state, me, { type: "creatureAttack", creatureId: id, targetId: me }).ok).toBe(false); + }); + + it("the troll rolls a D4 for damage and regenerates at its creator's turn end", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "troll"); + state = r.state; + const troll = state.creatures[0]!; + + // Hurt the troll, then watch a point come back at end of turn. + const fb = giveCard(state, me, "fireball", "F", 1); + // (can't attack again this turn — adrenaline not in play — so wound it via + // test surgery instead) + void fb; + troll.damage = 3; + state = must(state, me, { type: "endTurn", draw: 0 }); + expect(state.creatures[0]!.damage).toBe(2); + }); + + it("the wraith slips through one wall per turn and its touch steals cards", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "wraith"); + state = r.state; + const wraith = state.creatures[0]!; + + // Walk it through a wall if one is adjacent. + const view = boardView(state); + for (const side of SIDES) { + const t = stepTarget(view, wraith.position, side); + if (t.kind === "blocked" && t.by === "wall") { + const dest = { x: wraith.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0), + y: wraith.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + if (!view.cells[cellKey(dest)]) continue; + state = must(state, me, { type: "moveCreature", creatureId: wraith.id, direction: side }); + expect(state.creatures[0]!.wallPassUsed).toBe(1); + break; + } + } + + // Its touch: 2 damage and a random card lost. March it onto the enemy. + state = must(state, me, { type: "endTurn", draw: 0 }); + const enemy = state.players.find((p) => p.id !== me)!; + state = must(state, enemy.id, { type: "endTurn", draw: 0 }); + const w = state.creatures[0]!; + const enemy2 = state.players.find((p) => p.id !== me)!; + enemy2.position = { ...w.position }; + // step the wraith one cell and back onto the enemy? Simply move enemy onto + // wraith is not a touch (wraith must enter). Move wraith away then back. + const view2 = boardView(state); + for (const side of SIDES) { + const t = stepTarget(view2, w.position, side); + if (t.kind === "step") { + const back: Side = side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E"; + state = must(state, me, { type: "moveCreature", creatureId: w.id, direction: side }); + state = must(state, me, { type: "moveCreature", creatureId: w.id, direction: back }); + break; + } + } + const bitten = state.players.find((p) => p.id !== me)!; + expect(bitten.life).toBe(13); + expect(bitten.hand.length).toBe(6); + }); + + it("the fire imp scorches on sight and dies only to water", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "fire-imp"); + state = r.state; + const imp = state.creatures[0]!; + + // Fireball cannot destroy it... + state = must(state, me, { type: "endTurn", draw: 0 }); + const enemy = state.players.find((p) => p.id !== me)!; + // (enemy may have been scorched at turn start if in LOS — note life) + const enemyLife = enemy.life; + void enemyLife; + const enemyNow = state.players.find((p) => p.id !== me)!; + enemyNow.position = { ...imp.position }; // stand at the imp for clear sight + const fb = giveCard(state, enemy.id, "fireball", "F", 0); + state = must(state, enemy.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id }, + }); + expect(state.creatures.length).toBe(1); + + // ...but a waterbolt douses it instantly. + state = must(state, enemy.id, { type: "endTurn", draw: 0 }); + state = must(state, me, { type: "endTurn", draw: 0 }); + state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position }; + const wb = giveCard(state, enemy.id, "waterbolt", "W", 0); + state = must(state, enemy.id, { + type: "cast", instanceId: wb.instanceId, target: { kind: "creature", creatureId: imp.id }, + }); + expect(state.creatures.length).toBe(0); + }); + + it("the democratic monster is moved by every player", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "democratic-monster"); + state = r.state; + const id = state.creatures[0]!.id; + state = must(state, me, { type: "endTurn", draw: 0 }); + const other = activePlayer(state).id; + expect(other).not.toBe(me); + const view = boardView(state); + for (const side of SIDES) { + if (stepTarget(view, state.creatures[0]!.position, side).kind === "step") { + const result = applyCommand(state, other, { type: "moveCreature", creatureId: id, direction: side }); + expect(result.ok).toBe(true); + break; + } + } + }); + + it("mega-monster doubles a monster's toughness; dispel un-creates it", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "skeleton"); + state = r.state; + const id = state.creatures[0]!.id; + const mm = giveCard(state, me, "mega-monster", "MM", 1); + state = must(state, me, { + type: "cast", instanceId: mm.instanceId, target: { kind: "creature", creatureId: id }, + }); + expect(state.creatures[0]!.maxDamage).toBe(8); + + const dc = giveCard(state, me, "dispel-creation", "DC", 2); + state = must(state, me, { + type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: state.creatures[0]!.position }, + }); + expect(state.creatures.length).toBe(0); + }); + + it("monsters vanish when their creator dies", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "skeleton"); + state = r.state; + const creator = state.players.find((p) => p.id === me)!; + const enemy = state.players.find((p) => p.id !== me)!; + creator.life = 1; + enemy.position = { ...creator.position }; + state = must(state, me, { type: "endTurn", draw: 0 }); + state = must(state, enemy.id, { type: "punch", targetId: me }); + state = must(state, me, { type: "pass" }); + expect(state.players.find((p) => p.id === me)!.alive).toBe(false); + expect(state.creatures.length).toBe(0); + }); +}); + +describe("expansion support cards", () => { + it("adrenaline allows a second attack in one turn", () => { + let { state } = newGame(); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + defender.position = { ...attacker.position }; + const adr = giveCard(state, attacker.id, "adrenaline"); + giveCard(state, attacker.id, "number-2", "N", 1); + state = must(state, attacker.id, { + type: "cast", instanceId: adr.instanceId, numberInstanceIds: ["number-2#N"], + }); + const fb1 = giveCard(state, attacker.id, "fireball", "F1", 0); + state = must(state, attacker.id, { type: "cast", instanceId: fb1.instanceId, target: { kind: "player", playerId: defender.id } }); + state = must(state, defender.id, { type: "pass" }); + const fb2 = giveCard(state, attacker.id, "fireball", "F2", 0); + state = must(state, attacker.id, { type: "cast", instanceId: fb2.instanceId, target: { kind: "player", playerId: defender.id } }); + state = must(state, defender.id, { type: "pass" }); + expect(state.players.find((p) => p.id === defender.id)!.life).toBe(5); + // A third is refused. + const fb3 = giveCard(state, attacker.id, "fireball", "F3", 0); + expect(applyCommand(state, attacker.id, { + type: "cast", instanceId: fb3.instanceId, target: { kind: "player", playerId: defender.id }, + }).ok).toBe(false); + }); + + it("shadow costs a life point per turn and dies to any damage", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const r = summon(state, me, "shadow"); + state = r.state; + expect(state.creatures[0]!.kind).toBe("shadow"); + const lifeBefore = state.players.find((p) => p.id === me)!.life; + + // Around to my next turn: upkeep costs 1. + state = must(state, me, { type: "endTurn", draw: 0 }); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + expect(state.players.find((p) => p.id === me)!.life).toBe(lifeBefore - 1); + + // Any damage destroys it. + const enemy = state.players.find((p) => p.id !== me)!.id; + state = must(state, me, { type: "endTurn", draw: 0 }); + state.players.find((p) => p.id === enemy)!.position = { ...state.creatures[0]!.position }; + const fb = giveCard(state, enemy, "fireball", "F", 0); + state = must(state, enemy, { + type: "cast", instanceId: fb.instanceId, + target: { kind: "creature", creatureId: state.creatures[0]!.id }, + }); + expect(state.creatures.length).toBe(0); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d864f46..dcb1429 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -109,7 +109,7 @@ wss.on("connection", (socket) => { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" }); - const result = startGame(room); + const result = startGame(room, msg.expansion === true); if ("error" in result) return send(socket, { type: "error", message: result.error }); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); broadcastRoomState(room); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index c1bcf15..69a371b 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -84,14 +84,14 @@ export function joinRoom( return { token: fresh }; } -export function startGame(room: Room): { events: GameEvent[] } | { error: string } { +export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } { if (room.state) return { error: "already started" }; const n = room.players.length; if (n !== 2 && n !== 4) return { error: "supported player counts: 2 or 4" }; const { state, events } = createGame({ playerIds: room.players, seed: room.seed, - sets: ["basic"], + sets: expansion ? ["basic", "expansion1"] : ["basic"], }); room.state = state; room.events.push(...events); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 0e544eb..30f35da 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -9,6 +9,9 @@ let name = $state(""); let joinCode = $state(""); let drawCount = $state(2); + let withExpansion = $state(true); + /** Your creature selected for movement/attacks. */ + let selectedCreature = $state(null); /** Card selected in hand, pending a target. */ let selectedCard = $state(null); @@ -44,7 +47,9 @@ const CELL_CARDS = new Set([ "teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag", "rotate-sector", "relocate-sector", + "troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow", ]); + const CREATURE_TARGET_CARDS = new Set(["mega-monster"]); const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]); const SELF_CARDS = new Set([ "invisible", "shrink", "mist-body", @@ -57,6 +62,7 @@ const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1); function clearSelection() { + selectedCreature = null; selectedCard = null; attachedNumber = null; attachedMods = []; @@ -126,7 +132,7 @@ // Instant untargeted spells (and stone displays) cast immediately; // duration self-spells wait so a number card can be attached. const INSTANT = new Set([ - "speed", "pass-through-wall", "reuse-spell", "ugly", + "speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash", "bloodstone", "brainstone", "powerstone", "shadowstone", "shieldstone", "soulstone", "speedstone", "visionstone", ]); @@ -190,6 +196,21 @@ clearSelection(); return; } + if (selectedCreature) { + const creature = view.creatures.find((c) => c.id === selectedCreature); + if (creature) { + for (const side of SIDES) { + const n = { x: creature.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0), + y: creature.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + if (cellKey(n) === cellKey(cell)) { + net.command({ type: "moveCreature", creatureId: selectedCreature, direction: side }); + return; + } + } + } + selectedCreature = null; + return; + } const me = view.players.find((p) => p.id === view.you)!; // A cell click is a move if the cell is one legal step away (the server // also lets doors/walls pass when unlocked/misted — try the direction). @@ -222,8 +243,38 @@ clearSelection(); } + function clickCreature(creatureId: string) { + if (!view || !isYourTurn) return; + const creature = view.creatures.find((c) => c.id === creatureId); + if (!creature) return; + if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) { + net.command({ + type: "cast", instanceId: selectedCard.instanceId, + target: { kind: "creature", creatureId }, + ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), + }); + clearSelection(); + return; + } + if (selectedCreature && selectedCreature !== creatureId) { + // Your selected creature attacks another creature in its square. + net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId }); + selectedCreature = null; + return; + } + const mine = creature.controllerId === view.you || creature.kind === "democratic-monster"; + if (mine) { + selectedCreature = selectedCreature === creatureId ? null : creatureId; + } + } + function clickPlayer(playerId: string) { if (!view || !isYourTurn) return; + if (selectedCreature) { + net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId }); + selectedCreature = null; + return; + } if (!selectedCard) { // No card selected: same-square click = punch. const me = view.players.find((p) => p.id === view.you)!; @@ -307,9 +358,10 @@ {/each} {#if net.you === net.hostId} + @@ -323,9 +375,11 @@ diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 78e2aab..091810e 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -8,15 +8,19 @@ let { view, edgeSelectMode = false, + selectedCreatureId = null, onCellClick, onEdgeClick, onPlayerClick, + onCreatureClick, }: { view: GameView; edgeSelectMode?: boolean; + selectedCreatureId?: string | null; onCellClick?: (cell: { x: number; y: number }) => void; onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void; onPlayerClick?: (playerId: string) => void; + onCreatureClick?: (creatureId: string) => void; } = $props(); const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"]; @@ -187,6 +191,28 @@ {/each} {/each} + + {#each view.creatures as c (c.id)} + {@const ccx = c.position.x * CELL + CELL * 0.72} + {@const ccy = c.position.y * CELL + CELL * 0.7} + { ev.stopPropagation(); onCreatureClick?.(c.id); }} + onkeydown={() => {}} + > + + {c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg + + {c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()} + + {/each} + {#each edgeHitboxes as h (`${h.cell.x},${h.cell.y},${h.side}`)} 0 ? `The ${e.creatureId} takes ${e.amount} damage.` : `The attack has no effect on it.`; + case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`; + case "trollRegenerated": return `The troll's stony hide knits itself back together.`; + case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`; + case "impScorches": return `The fire imp scorches ${e.player}!`; + case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`; case "trapRedrawnDuringDeal": return null; case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`; case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`; @@ -150,8 +160,8 @@ class Net { this.send({ type: "join", roomId, name, token: this.token }); } - start(): void { - this.send({ type: "start" }); + start(expansion: boolean): void { + this.send({ type: "start", expansion }); } command(command: Command): void {