From df0675c7355b9cf85e70526dff111995121b5f28 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 15 Aug 2026 22:41:54 -0400 Subject: [PATCH] Expansion wave 1: the creature system and first eight monster cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/engine/data/VERIFICATION.md | 11 +- packages/engine/data/cards.json | 3 +- packages/engine/src/game.ts | 428 ++++++++++++++++++++++++- packages/engine/src/view.ts | 3 + packages/engine/test/creatures.test.ts | 333 +++++++++++++++++++ packages/server/src/index.ts | 2 +- packages/server/src/rooms.ts | 4 +- packages/web/src/App.svelte | 58 +++- packages/web/src/Board.svelte | 30 ++ packages/web/src/net.svelte.ts | 14 +- 10 files changed, 872 insertions(+), 14 deletions(-) create mode 100644 packages/engine/test/creatures.test.ts 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 {