diff --git a/.claude/skills/wizwar-duel/SKILL.md b/.claude/skills/wizwar-duel/SKILL.md new file mode 100644 index 0000000..5141d5f --- /dev/null +++ b/.claude/skills/wizwar-duel/SKILL.md @@ -0,0 +1,140 @@ +--- +name: wizwar-duel +description: Play Wiz-War live against Eric — join his room as a real seat, reason about every move yourself, and duel turn by turn over the websocket. Use when Eric wants a game, gives a room code, or says "let's play". +--- + +# Playing Wiz-War against Eric + +You are a PLAYER, not the automaton. Read the board, reason, and choose +every command yourself. Eric plays in his browser; you play through the +CLI seat at `tools/claude-seat.mjs`. He prompts "go" after his moves — +each of his messages is your cue to look and act. + +## The seat client + +Run from the repo root (needs the workspace's `ws` package): + +```bash +node tools/claude-seat.mjs join Claude # take a seat (token saved) +node tools/claude-seat.mjs view # board + hand + last events +node tools/claude-seat.mjs do '' # one engine command +node tools/claude-seat.mjs chat "text" # table talk +``` + +Seat state persists in `/tmp/wizwar-claude-seat.json` (override: +`WIZWAR_SEAT`); server defaults to production (`WIZWAR_SERVER` to point +elsewhere). Eric creates the room, gives you the code, and starts the +game after you join. + +Commands are the engine's `Command` union (packages/engine/src/game.ts): +`{"type":"move","direction":"N|S|E|W"}`, `cast` (with `target`, optional +`numberInstanceIds`/`params`), `playNumberForMovement`, `pickUpTreasure`, +`dropTreasure`, `warpStep`, `counteract`, `pass`, `punch`, `setAmbush`, +`wardChoice`, `endTurn` (with `draw`). A refused command costs nothing — +probing legality is free, so when unsure, try it and read the error. + +## Reading the map + +- The renderer prints y=0 at top; **y grows SOUTH**. N = y-1, S = y+1. +- `C2` is you, `P1` etc. are opponents, `$c`/`$1` treasures, `h?` homes, + two-letter codes are square contents (SA safe, RO rosebush, ST stone, + DU dust). `D` on a line is a door, `—` a wall, blank is open. +- WARPS line lists rim passages as `2,0N→2,9`: standing at (2,0) and + moving N carries you to (2,9). **The warp rides one specific side of + its square** — check which before you walk (a wrong guess burns moves; + ask me how I know). +- Dimensional-warp TOKENS (cast by players) are separate: stand on one + and `warpStep`. Creatures use `creatureWarpStep`. + +## Hard-won rules knowledge (each cost me something in game one) + +- `pickUpTreasure` ENDS your turn's actions AND movement — arrive with + the grab as your last act, never mid-plan. `dropTreasure` at home too. +- You can pick up ANY floor treasure, including your own stolen-and- + delivered one sitting on the enemy's home square. Repossession is real. +- THIEF and punches need same-square; THIEF steals a *named* card and + fizzles (card spent) on a wrong guess. +- MENTAL FORCE moves the victim ≤3 *walked* spaces — walls and relocked + doors shrink its reach; rev 5+ refuses impossible destinations. +- Doors relock behind you when you pass through (rev 4+). Your own + escape route can seal itself — and seal pursuers out. +- LOS threads through rim warps: you can be seen (and shot) through a + warp mouth from the far side of the board. Camping one square off the + mouth keeps you hidden. +- lock-in-place freezes movement AND warpStep for a full turn; REUSE + SPELL retrieves only your LAST cast spell, enabling one re-lock. +- An ambush (`setAmbush` interrupt/opportunity-fire + committed attack) + triggers on LOS *entry* transitions only — someone already in sight + never springs it. It survives forever and fires out of turn. +- Walking-dead bleeds ½ life per space moved, permanently. Once cursed, + every plan must be priced in steps. + +## Game-two scars (each cost me the game or nearly) + +- **RECOUNT THE SCOREBOARD EVERY TURN**: how many treasures does each + player have banked AT HOME right now? I sprinted a "winning" delivery + in game two that was actually my FIRST of two — Eric had un-banked me + five rounds earlier and let me monologue. Banked counts, not vibes. +- A banked treasure is NOT safe: anyone can pick it up off your home + square and carry it away. Guard the bank or count it as contested. +- LOS is FREE-ANGLE center-to-center (the rulebook's rule), not rows + and columns: razor diagonals threading open gaps are legal for + everyone — including at you. The board draws bent/straight traces. +- ILLUSIONARY ATTACK deals its imitated spell's damage if NOT + countered; ANY counteraction dispels it entirely. Counter it with + the cheapest thing you hold; never "call the bluff" by passing. +- Never trust your ASCII parse of the map for multi-step plans: run a + BFS over `view.board.edges` (see tools/claude-seat.mjs view JSON, or + write a quick router) before spending a number card on a sprint. I + burned a 7-move turn bouncing off six misread walls. +- Eric counter-plays YOUR bot upgrades: he summoned a troll bodyguard + specifically against the archmage's exile-the-winner instinct, and + relocated MY home sector mid-carry. Expect meta-warfare. +- In 4p with automatons: the bots are chaos agents — they stun, exile, + disease, and con. Herd them at Eric; never assume they're on script. + +## Game-three scars (lost in four rounds) + +- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel has won two games + with quietly-placed wormholes ending beside his home. The client now + prints a !! line for them; treat any new token pair as a five-alarm + fire and recompute both players' delivery distances through it. +- **Never lead the attack against Kestrel.** Three games, three saved + counters sprung at the perfect moment (anti-anti, full-shield bait, + full-reflection held three rounds for my lightning). Force his + counters out with throwaways or positional threats before committing + a real spell; his patience is perfect and he telegraphs nothing. +- DESTROY WALL showers the ADJACENT caster with rubble (4 damage) — + cast it from range, never on your own square's edge. +- Wading a rosebush costs 3. A reflected LIGHTNING BLAST still stuns + its caster even at 1 reflected damage — the stun is the payload, and + a lost turn against Kestrel is a lost game. +- Grab-turn-one openings feel great and prove nothing: deliveries are + the only score. He banked quietly both times while I showboated. + +## Playing well + +- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for + my full shield, and won game one with a home-to-home dimensional-warp + superhighway. Infrastructure beats sprinting — watch what he builds, + and consider your own wormhole early. +- Win = 2 enemy treasures delivered to your home, or last wizard alive. + Track BOTH players' step-counted delivery timelines every turn; the + race is decided in tempo, not damage. +- Hold counters (full-shield, absorb) for permanent curses and lethal + damage. Absorb soaks 3 points; it cannot touch durations. +- OPSEC: your prose is visible to Eric. NEVER name the cards you hold or + draw, and never announce plans. (Game one: I narrated my hand like a + debug log until he asked why I was making it easy.) Trash talk freely + — about the board, never your hand. +- Send `chat` for table talk at dramatic moments; it's half the fun. + +## Cadence + +1. On "go": `view`, read HIS events since your last turn (the client + prints only NEW events; `view` shows a short history tail). +2. Think about the whole board — his timeline, yours, threats, LOS. +3. Execute your commands one at a time, checking errors. +4. `endTurn` with a draw that respects the 7-card hand limit. +5. Tell Eric it's his turn — reasoning aloud is fine, hand contents are + not. When a stack waits on him, say so and wait for "go". diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 5dc5de8..c6ef254 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -9,6 +9,7 @@ import { cardDef, type CardInstance } from "./cards"; import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; import { bentSightFor, sightedCellsFor, type GameView } from "./view"; +import { wallIgnoringDistance } from "./game"; import type { AmbushTrigger, Command, PlayerId } from "./game"; export type AutomatonStyle = "hunter" | "berserker" | "worrier"; @@ -293,6 +294,17 @@ function pathDenial(view: GameView): Command | null { if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! }); } } + // Enemy gold banked at MY home: my score, snatchable by anyone. A + // raider closing on the bank gets the same road-lengthening treatment. + const me2 = me(view); + const banked = view.treasures.find( + (t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home)); + if (banked) { + for (const e of livingEnemies(view)) { + const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y); + if (d <= 6) threats.push({ enemy: e.position, goal: me2.home }); + } + } if (threats.length === 0) return null; const sighted = sightedCellsFor(view); @@ -358,7 +370,12 @@ function pathDenial(view: GameView): Command | null { ); } // Never brick the square the treasure needs to stay reachable on. - if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) { + // TACKS are scattered at the caster's feet ("you must be adjacent"), + // not thrown — offering a distant square wedges the whole brain on + // an eternally refused cast. + const fillerReaches = !filler || filler.cardId !== "handful-of-tacks" || + Math.abs(b.x - me(view).position.x) + Math.abs(b.y - me(view).position.y) <= 1; + if (filler && fillerReaches && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) { consider( { type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } }, { avoidCell: cellKey(b) }, @@ -597,7 +614,7 @@ function pathToward( const hazard = view.squareContents[k]?.kind; if (!opts.throughHazards && (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || - hazard === "rosebush" || hazard === "slime")) continue; + hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue; if (opts.avoidNearEnemies && nearEnemy(to)) continue; next.push(to); } @@ -630,7 +647,16 @@ function treasureGoals(view: GameView): Set { } for (const t of view.treasures) { if (!t.position || t.carriedBy) continue; - if (t.owner === view.you) continue; + if (t.owner === view.you) { + // REPOSSESSION: my own gold banked at an enemy's home is a point on + // THEIR scoreboard. Marching to take it back is worth the detour + // only when that enemy is one delivery from winning — constant + // re-stealing farms nothing and stalls the whole table. + const banker = view.players.find( + (p) => p.id !== view.you && cellKey(p.home) === cellKey(t.position!)); + if (banker && deliveryWins(view, banker)) goals.add(cellKey(t.position)); + continue; + } if (cellKey(t.position) === cellKey(self.home)) continue; goals.add(cellKey(t.position)); } @@ -645,6 +671,15 @@ function bushOrMist(view: GameView, id: PlayerId): boolean { view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id); } +/** Would this wizard's delivery, landed, win them the game right now? */ +function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTreasureId: string | null }): boolean { + if (!p.carriedTreasureId) return false; + const carried = view.treasures.find((t) => t.id === p.carriedTreasureId); + if (!carried || carried.owner === p.id) return false; + return view.treasures.some( + (t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home)); +} + /** The wizard making off with MY gold, if any. */ function thiefOfMine(view: GameView) { const carriers = new Set( @@ -722,7 +757,16 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma return { type: "pass" }; } - const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift; + // The LAST counter in hand is a treasure of its own: at a healthy life + // total it waits for something big rather than answering every jab — + // patience the clockwork's best opponent taught it, three counters at + // a time. (Lethal blows override this below.) + const counterCount = view.yourHand.filter((c) => { + const t = cardDef(c.cardId).cardType; + return t === "counteraction" || t === "neutral/counteraction"; + }).length; + const lastCounterHold = counterCount <= 1 && me(view).life >= 10 ? 2 : 0; + const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift - lastCounterHold; const attackId = stack.attackCard?.cardId ?? null; const atk = attackId ? ATTACKS[attackId] : null; const affliction = attackId ? AFFLICTIONS[attackId] != null : false; @@ -749,19 +793,24 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma ? Math.max(2, stack.numberValue ?? 2) : (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) : 1; + // A blow that would be the last one outranks every thrift threshold: + // when the incoming points reach the clockwork's life, any counter + // that can save it is cheap at the price. + const lethal = !affliction && incoming >= me(view).life; + const weight = lethal ? Math.max(incoming, 9) : incoming; if (stack.kind === "spell") { // REVERSE eats points; a pure duration offers it nothing. const reverse = find("reverse"); - if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId }; + if (reverse && !affliction && weight >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId }; // ABSORB SPELL steals the good ones for later. const absorbSpell = find("absorb-spell"); - if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId }; + if (absorbSpell && weight >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId }; const shield = find("full-shield"); - if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) { + if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) { return { type: "counteract", instanceId: shield.instanceId }; } const reflect = find("full-reflection"); - if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId }; + if (reflect && weight >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId }; // A curse in flight is best refused at the door. if (affliction) { const cleanse = find("remove-curse"); @@ -777,9 +826,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma const permanentCurse = affliction && (attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot"); const absorb = find("absorb"); - if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId }; + if (absorb && !affliction && weight >= 2 - flinch && (incoming <= 3 || (lethal && incoming - 3 < me(view).life))) { + return { type: "counteract", instanceId: absorb.instanceId }; + } const blunt = find("blunt"); - if (blunt && !permanentCurse && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId }; + if (blunt && !permanentCurse && weight >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId }; // The berserker shares its pain out of spite — but only pain: a // damage-less curse gives EMPATHY nothing to mirror. const empathy = find("empathy"); @@ -788,13 +839,13 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma } // Nothing to block with: teleport clear of the big ones. const tp = find("teleport"); - if (tp && incoming >= 4 - flinch) { + if (tp && weight >= 4 - flinch) { const out = escapeCell(view, me(view).position); if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } }; } // Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after. const vanish = find("invisible"); - if (vanish && incoming >= 3 - flinch) { + if (vanish && weight >= 3 - flinch) { const num = numbersInHand(view)[0]; return { type: "counteract", instanceId: vanish.instanceId, @@ -814,12 +865,44 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma /** The best attack available against a visible target, numbers and amplify included. */ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null { const numbers = numbersInHand(view); + // TURN THEFT: against a carrier one delivery from winning, a + // LIGHTNING BLAST's stolen turn outranks any point total — the number + // rides along so soaks cannot zero the damage and void the stun. + { + const carrier = view.players.find((p) => p.id === targetId); + const bolt = inHand(view, "lightning-blast"); + const biggestNum = numbers[numbers.length - 1]; + if (carrier && bolt && biggestNum && deliveryWins(view, carrier)) { + return { + damage: cardDef(biggestNum.cardId).value ?? 1, + cmd: { + type: "cast", instanceId: bolt.instanceId, + target: { kind: "player", playerId: targetId }, + numberInstanceIds: [biggestNum.instanceId], + }, + }; + } + } const biggest = numbers[numbers.length - 1]; const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0; const target = view.players.find((p) => p.id === targetId)!; const together = cellKey(target.position) === cellKey(me(view).position); const amplify = tier.amplify ? inHand(view, "amplify") : undefined; - let best: { cmd: Command; damage: number } | null = null; + // A killing blow at minimal spend outranks maximum splash — but only + // when it clears the target's life with soak to spare: an exact-lethal + // blow dies to one ABSORB, so thrift begins three points past the kill. + type BestPick = { cmd: Command; damage: number; kill: boolean; cost: number }; + let best: BestPick | null = null; + const offer = (damage: number, cost: number, cmd: Command) => { + const rank = (d: number) => (d >= target.life + 3 ? 2 : d >= target.life ? 1 : 0); + const r = rank(damage); + const better = !best ? true + : r !== rank(best.damage) ? r > rank(best.damage) + : r === 2 ? cost < best.cost + : damage !== best.damage ? damage > best.damage + : cost < best.cost; + if (better) best = { cmd, damage, kill: damage >= target.life, cost }; + }; for (const c of view.yourHand) { const atk = ATTACKS[c.cardId]; if (!atk) continue; @@ -831,25 +914,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd const isWand = c.cardId === "blaster-wand"; const uncharged = isWand && view.wandCharges[c.instanceId] == null; if ((uncharged || atk.needsNumber) && !biggest) continue; - const withNumber = (atk.perNumber || uncharged) && biggest; - let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0); - if (damage <= 0) continue; - // AMPLIFY doubles the heavy hitters (spells only, not thrown things). - const amplified = amplify && damage >= 4 && !isWand && - c.cardId !== "dagger" && c.cardId !== "large-rock"; - if (amplified) damage *= 2; - if (!best || damage > best.damage) { - best = { - damage, - cmd: { + const mustNumber = uncharged || atk.needsNumber === true; + const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock"; + const numberChoices: (CardInstance | undefined)[] = atk.perNumber + ? [undefined, ...numbers] + : [mustNumber ? biggest : undefined]; + for (const num of numberChoices) { + if (mustNumber && !num) continue; + const value = num ? (cardDef(num.cardId).value ?? 0) : 0; + const base = atk.base + (atk.perNumber && num ? value : 0); + if (base <= 0) continue; + for (const amp of canAmplify && base >= 4 ? [false, true] : [false]) { + const damage = amp ? base * 2 : base; + offer(damage, value + (amp ? 5 : 0), { type: "cast", instanceId: c.instanceId, target: { kind: "player", playerId: targetId }, // An illusion needs a spell to imitate; a fireball sells best. ...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}), - ...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}), - ...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}), - }, - }; + ...(num ? { numberInstanceIds: [num.instanceId] } : {}), + ...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}), + }); + } } } // STONE DEAD: number times the stones the victim carries — the displayed @@ -858,9 +943,10 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd if (sd && biggest) { const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length; const dmg = biggestValue * shown; - if (dmg >= 4 && dmg > (best?.damage ?? 0)) { + const cur = best as BestPick | null; + if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) { best = { - damage: dmg, + damage: dmg, kill: dmg >= target.life, cost: biggestValue, cmd: { type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: targetId }, @@ -869,7 +955,8 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd }; } } - return best; + const picked = best as BestPick | null; + return picked ? { cmd: picked.cmd, damage: picked.damage } : null; } /** An affliction worth casting when no damage lands, mid number attached. @@ -957,6 +1044,14 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm return { type: "cast", instanceId: c.instanceId }; } } + // So does the MASTER KEY: displayed, every workable lock in the maze + // opens under its bearer's hand without another cast. + { + const mk = inHand(view, "master-key"); + if (mk && !self.displayed.some((d) => d.instanceId === mk.instanceId)) { + return { type: "cast", instanceId: mk.instanceId }; + } + } if (!tier.buffs) return null; // the apprentice's book ends at the stones // A curse on the clockwork gets scrubbed off. const cursed = view.sustained.some( @@ -1036,6 +1131,43 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm } } } + // INFRASTRUCTURE: a wormhole anchored beside home turns every future + // delivery into a three-move stroll — the pattern that won its best + // opponent two games. Placed early, from home's doorstep, far mouth + // by the richest distant gold. + { + const dwarp = inHand(view, "dimensional-warp"); + const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1; + if (dwarp && nearHome && view.dimWarps.length === 0 && view.turn.round <= 6) { + const legal = (c: Cell) => + view.board.cells[cellKey(c)] !== undefined && + !view.board.homes.some((h) => h.x === c.x && h.y === c.y) && + !view.squareContents[cellKey(c)] && + !view.treasures.some((t) => t.position && cellKey(t.position) === cellKey(c)) && + (view.groundObjects[cellKey(c)] ?? []).length === 0; + const goals = treasureGoals(view); + if (legal(self.position) && goals.size > 0) { + const dHome = distancesFrom(view, [self.home], false); + const sighted = sightedCellsFor(view); + let far: { cell: Cell; worth: number } | null = null; + for (const g of goals) { + const [gx, gy] = g.split(",").map(Number) as [number, number]; + for (const side of SIDES) { + const c = neighbor({ x: gx, y: gy }, side); + if (!legal(c) || !sighted.has(cellKey(c))) continue; + const worth = dHome.get(cellKey(c)) ?? 0; + if (worth >= 8 && (far === null || worth > far.worth)) far = { cell: c, worth }; + } + } + if (far) { + return { + type: "cast", instanceId: dwarp.instanceId, + params: { cell: self.position }, target: { kind: "cell", cell: far.cell }, + }; + } + } + } + } // Guard the gold on the floor: a SAFE locks it, GLUE sticks it down. const myFloorTreasure = tier.guardGold ? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy) @@ -1128,10 +1260,13 @@ export function automatonCommand( if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; if (!self.carriedTreasureId) { const prize = view.treasures.find( - (t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here && - here !== cellKey(self.home), + (t) => t.position && !t.carriedBy && cellKey(t.position) === here && + here !== cellKey(self.home) && + (t.owner !== you || + // Repossessing my own gold off an enemy's home square. + view.players.some((p) => p.id !== you && cellKey(p.home) === here)), ); - if (prize) return { type: "pickUpTreasure" }; + if (prize) return { type: "pickUpTreasure", treasureId: prize.id }; } // Wounded clockwork teleports clear of visible hunters. @@ -1200,13 +1335,16 @@ export function automatonCommand( const target = thief && visible.some((p) => p.id === thief.id) ? thief : visible.sort((a, b) => a.life - b.life)[0]!; - // DROP OBJECT shakes my treasure out of the thief's hands. - if (thief && target.id === thief.id) { - const dob = inHand(view, "drop-object"); - if (dob) { + // DROP OBJECT shakes the treasure out of the hands that matter: + // the thief of my gold, or any carrier whose delivery wins. + { + const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief + : visible.find((p) => deliveryWins(view, p)); + const dob = mark ? inHand(view, "drop-object") : undefined; + if (mark && dob) { return { type: "cast", instanceId: dob.instanceId, - target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" }, + target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" }, }; } } @@ -1216,6 +1354,7 @@ export function automatonCommand( if (exile) { const carrier = visible.find((p) => { if (thief && p.id === thief.id) return true; + if (deliveryWins(view, p)) return true; if (!p.carriedTreasureId) return false; return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6; }); @@ -1365,6 +1504,24 @@ export function automatonCommand( } } + // TELEPORT DELIVERY: carrying with home four wall-ignoring spaces away + // but farther by boot — blink in and drop next command. The jump beats + // any ambush camped on the walking road. + if (self.carriedTreasureId && !view.turn.actionsEnded) { + const tpHome = inHand(view, "teleport"); + if (tpHome) { + const blink = wallIgnoringDistance(view.board, self.position, self.home); + const movesLeft = view.turn.movementAllowance - view.turn.movementUsed; + const dWalk = blink >= 1 && blink <= 4 + ? distancesFrom(view, [self.position], UNLOCKS.some((id) => inHand(view, id))) + .get(cellKey(self.home)) ?? Infinity + : 0; + if (blink <= 4 && blink >= 1 && dWalk > movesLeft) { + return { type: "cast", instanceId: tpHome.instanceId, target: { kind: "cell", cell: { ...self.home } } }; + } + } + } + // March. The thief-chase outranks everything; the berserker hunts wizards // over gold; the worrier keeps its distance; the hunter goes to the gold. if (view.turn.movementUsed < view.turn.movementAllowance) { @@ -1374,19 +1531,38 @@ export function automatonCommand( const ownGoldCells = new Set(view.treasures .filter((t) => t.owner === view.you && t.position && !t.carriedBy) .map((t) => cellKey(t.position!))); - // A thief already underfoot is not a destination — with the chase - // moot, march on the real goals (deliver, grab) instead of standing - // on them forever. - const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null; + // The chase exists to deliver a blow, so it claims the march only + // while that blow is still live: an attack in hand, this turn's + // attack unspent, casting not yet closed. Those gates hold still as + // the clockwork walks — a goal keyed to the thief's nearness flips + // underfoot and shuttles the walker on and off their square. Caught + // with the strike still live, it stands its ground (a goal underfoot + // ends the march); otherwise the gold has its legs for the turn. + const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden && + !view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null); + const chasing = thief !== null && strikeLive; + // BANK GUARD: enemy gold delivered to my home is my scoreboard, and + // anyone may snatch it off the floor. A raider nearer my bank than I + // am, with the bank stocked, outranks the next grab — run home. + const bankedCount = view.treasures.filter( + (t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length; + // The predicate reads only ENEMY positions — my own steps must not + // flip it mid-march or the walker shuttles (the thief-chase lesson). + const bankThreatened = bankedCount > 0 && !self.carriedTreasureId && + livingEnemies(view).some((p) => + !p.carriedTreasureId && + Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2); const objectives = cursedIdiot && ownGoldCells.size > 0 ? ownGoldCells - : thiefAfar - ? new Set([cellKey(thiefAfar.position)]) - : style === "berserker" && !self.carriedTreasureId - ? (enemyCells.size > 0 ? enemyCells : gold) - : gold; + : chasing + ? new Set([cellKey(thief.position)]) + : bankThreatened + ? new Set([cellKey(self.home)]) + : style === "berserker" && !self.carriedTreasureId + ? (enemyCells.size > 0 ? enemyCells : gold) + : gold; const path = - pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thiefAfar, canUnlock }) ?? + pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ?? pathToward(view, self.position, enemyCells, { canUnlock }) ?? // No clean road to anything: grit the teeth and wade the hazards, @@ -1469,12 +1645,17 @@ export function automatonCommand( // The war chest: when an attack in hand wants a number, the // biggest one is reserved — a waterbolt unfired outbids a longer // march, and walking is free next turn. - const wantsNumber = view.yourHand.some((c) => { - const atk = ATTACKS[c.cardId]; - if (!atk) return false; - return atk.perNumber || atk.needsNumber === true || - (c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null); - }); + // — but a chest hoarded with no enemy in reach just slows the + // march, and no reserve outbids the delivery that wins the game. + const foesClose = livingEnemies(view).some((p) => + Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6); + const wantsNumber = foesClose && !deliveryWins(view, self) && + view.yourHand.some((c) => { + const atk = ATTACKS[c.cardId]; + if (!atk) return false; + return atk.perNumber || atk.needsNumber === true || + (c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null); + }); const spendable = wantsNumber ? numbers.slice(0, -1) : numbers; // A treasure within free walking ends this turn at the grab (the // gold-underfoot rung outranks everything) — a number played first diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index e450195..c65dc80 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -195,6 +195,9 @@ export interface CastStack { /** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */ cardId?: string }[]; waitingOn: PlayerId; + /** AROUND THE CORNER carried this attack: sight bent through a middle + * square, so the straight line the table would look for does not exist. */ + bentCorner?: true; /** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */ defenderShielded?: boolean; /** Set when a creature, not a wizard, delivers the attack. */ @@ -225,6 +228,9 @@ export interface CastParams { hold?: boolean; } +/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ +export const CURRENT_RULES_REV = 5; + export interface GameConfig { playerIds: PlayerId[]; seed: number; @@ -235,17 +241,18 @@ export interface GameConfig { colors?: number[]; /** * Rules revision, frozen per game so stored games replay unchanged. - * Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not - * applicable in a 2-player game."). Rev 3: WARD springs only when armed, - * and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4: - * creature blows open a counteraction window like any attack. Rev 5: - * BIG MAN pushes occupants ahead, steps over floor hazards for 2 points, - * and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a - * teleport escape ("does not work against escape" — the card face). - * Rev 7: no behavioral change — every board opening carries warp sight - * in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the - * warp." — the card face). Rev 8: nothing can be created on a warp token, - * and a SPEED bonus turn burns a turn of durations on the hastened wizard. + * Absent = rev 1, the baseline all earlier revisions collapsed into. + * Rev 2: a wizard beside a door they can open (lock removed, door + * unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through + * the doorway; the hallway behind them still cannot. + * Rev 3: VISIONSTONE pierces its one wall for every sight the game + * asks of its bearer — creations and utility spells included — not + * only direct attacks. + * Rev 4: "the door will relock behind you" means it — passing through + * an unlocked door shuts it at the walker's back unless a hand holds + * it; unpassed, it relocks at turn's end as before. + * Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to + * in three spaces, instead of eating the card silently at resolution. */ deckRev?: number; } @@ -348,19 +355,71 @@ export function losBlockers(state: GameState): Record { return blockers; } -/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still - * considered to block L.O.S." speaks of a CLOSED door, and - * the table holds doors open precisely to cast back through them. */ -function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard { +/** A door HELD open is propped ajar for every eye in the hallway. Any + * other door hangs shut, and a shut door blocks L.O.S. — see doorsAjar + * for the one wizard who may pull it open and peek. */ +function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard { if (state.heldDoors.length === 0) return board; const edges = { ...board.edges }; for (const h of state.heldDoors) delete edges[h.key]; return { ...board, edges }; } +/** A wizard at a door's threshold may pull it open and look through + * without stepping through: any wizard beside a door whose lock is + * REMOVED or that stands unlocked this turn, and a holder of PICK LOCK + * or MASTER KEY beside any workable lock. The door still hangs shut to + * everyone down the hallway. (Rules rev 2; earlier games replay with + * doors dark.) */ +function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: AssembledBoard): AssembledBoard { + if ((state.config.deckRev ?? 1) < 2 || !viewerId) return board; + const viewer = state.players.find((p) => p.id === viewerId); + if (!viewer || !viewer.alive) return board; + const carriesKey = viewer.hand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key"); + let edges: Record | null = null; + for (const side of SIDES) { + const key = edgeKey(viewer.position, side); + if (board.edges[key] !== "door") continue; + const workable = carriesKey && state.doorStates[key] !== "jammed"; + if (!workable && state.doorStates[key] !== "removed" && !state.openDoorEdges.includes(key)) continue; + if (!edges) edges = { ...board.edges }; + delete edges[key]; + } + return edges ? { ...board, edges } : board; +} + /** LOS including square-filling blockers. */ -export function gameLos(state: GameState, from: Cell, to: Cell): boolean { - return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state)); +function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { + const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); + const blockers = losBlockers(state); + if (sightBetween(board, from, to, blockers)) return true; + if (!stone) return false; + const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined; + if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false; + for (const key of Object.keys(board.edges)) { + if ((board.edges[key] ?? "open") === "open") continue; + const edges = { ...board.edges }; + delete edges[key]; + if (sightBetween({ ...board, edges }, from, to, blockers)) return true; + } + return false; +} + +/** LOS including square-filling blockers. VISIONSTONE is the bearer's + * sight wherever sight is asked of them — creations, dispels, utility + * spells — not just attacks: one wall or door, any type, falls away. + * Safe at every revision: widening what a cast may target never changes + * how a recorded command replays. */ +export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean { + return losWith(state, from, to, viewerId, true); +} + +/** The eye of an armed ambush. Springing is decided mid-move and lands + * in the ledger's flow, so this sight is frozen per game: before rev 3 + * an ambush never looked through its owner's VISIONSTONE, and stored + * games must replay that blindness. */ +function ambushLos(state: GameState, owner: PlayerState, from: Cell, to: Cell): boolean { + return losWith(state, from, to, owner.id, (state.config.deckRev ?? 1) >= 3); } /** Parse an edge key back into its north/west cell and side. */ @@ -409,7 +468,7 @@ function casterLos( from: Cell, to: Cell, ): boolean { - const board = openHeldDoors(state, perceivedBoard(state, caster.id)); + const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id))); const blockers = losBlockers(state); if (sightBetween(board, from, to, blockers)) return true; if (!displays(caster, "visionstone")) return false; @@ -525,6 +584,8 @@ export type GameEvent = | { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" } | { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean } + | { type: "creatureWarpStepped"; creatureId: string; from: Cell; to: Cell; by: PlayerId } + | { type: "mentalForceFizzled"; attacker: PlayerId; defender: PlayerId; cell: Cell } | { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell; /** Origins in FINAL coordinates (the maze may renormalize after the * landing); from/to record the pre-shift request. */ @@ -641,6 +702,7 @@ export type Command = | { type: "wardChoice"; play: boolean } | { type: "warpStep" } | { type: "moveCreature"; creatureId: string; direction: Side } + | { type: "creatureWarpStep"; creatureId: string } | { type: "creatureAttack"; creatureId: string; targetId: string } | { type: "cast"; @@ -693,7 +755,7 @@ type AttackEffect = { sustains?: boolean; /** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */ keepInHand?: boolean; - validate?: (state: GameState, cmd: Extract) => string | null; + validate?: (state: GameState, cmd: Extract, caster?: PlayerState, target?: PlayerState) => string | null; onResolved?: (ctx: ResolutionContext) => void; }; @@ -1182,8 +1244,14 @@ const CARD_EFFECTS: Record keepInHand: true, // "Unlocks any door (door relocks behind you). Do not discard when used. // Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK." + // Cast bare, it simply goes on display, and a DISPLAYED key turns in + // every lock its bearer walks through (see doMove) — no further casts. + // Cast at a door, it works that one lock like PICK LOCK and may hold + // the door open for others. resolve: (state, events, caster, cmd) => - unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }), + cmd.target + ? unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }) + : null, }, "remove-lock": { kind: "neutral", @@ -1306,7 +1374,7 @@ const CARD_EFFECTS: Record if (cmd.target.playerId === caster.id) return "you are already your own buddy"; const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!target || !target.alive) return "no such living player"; - if (!gameLos(state, caster.position, target.position)) return "no line of sight"; + if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight"; // Effectively permanent: broken by the caster attacking the target. attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS); return null; @@ -1474,14 +1542,14 @@ const CARD_EFFECTS: Record 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"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) 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"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; delete state.squareContents[key]; events.push({ type: "creationDispelled", caster: caster.id, what: content.kind }); return null; @@ -1498,7 +1566,7 @@ const CARD_EFFECTS: Record const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!target || !target.alive) return "no such living player"; if (target.id === caster.id) return "you cannot drag yourself"; - if (!gameLos(state, caster.position, target.position)) return "no line of sight"; + if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight"; if (isLockedInPlace(state, target.id)) return "they are locked in place"; const from = target.position; dragToward(state, target, caster.position); @@ -1507,7 +1575,7 @@ const CARD_EFFECTS: Record } if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; const objects = state.groundObjects[key]; const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key); if (objects && objects.length > 0) { @@ -1584,7 +1652,7 @@ const CARD_EFFECTS: Record resolve: (state, events, caster) => { for (const opp of state.players) { if (!opp.alive || opp.id === caster.id) continue; - if (!gameLos(state, caster.position, opp.position)) continue; + if (!gameLos(state, caster.position, opp.position, caster.id)) continue; if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue; retreatFromSight(state, events, opp, caster.position); } @@ -1680,7 +1748,7 @@ const CARD_EFFECTS: Record 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"; + if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight"; const boost = cmd.params?.boost === "movement" ? "movement" : "life"; if (boost === "movement") creature.movesPerTurn *= 2; else creature.maxDamage *= 2; @@ -1882,7 +1950,7 @@ const CARD_EFFECTS: Record (state.groundObjects[key] ?? []).length > 0 || state.treasures.some((t) => t.position && cellKey(t.position) === key); if (!hasObject) return "there is nothing there to glue down"; - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; state.gluedCells[key] = true; // "a duration equal to twice the NUMBER card played" const turns = magnitude.duration * 2; @@ -1906,7 +1974,7 @@ const CARD_EFFECTS: Record (state.groundObjects[key] ?? []).length > 0 || state.treasures.some((t) => t.position && cellKey(t.position) === key); if (!hasObject) return "there is nothing there to lock up"; - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id }; events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell }); return null; @@ -1924,7 +1992,7 @@ const CARD_EFFECTS: Record if (ka === kb) return "pick two different squares"; if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast"; if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe"; - if (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, b)) return "no line of sight"; + if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight"; const itemsA = state.groundObjects[ka] ?? []; const itemsB = state.groundObjects[kb] ?? []; const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka); @@ -1987,7 +2055,7 @@ const CARD_EFFECTS: Record if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block"; - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; delete state.squareContents[key]; events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell }); // "Solid stone block turns into a WATERWALL with a range and damage of 4." @@ -2103,11 +2171,19 @@ const CARD_EFFECTS: Record "mental-force": { kind: "attack", baseDamage: () => 0, // no LOS printed - validate: (state, cmd) => { + validate: (state, cmd, caster, target) => { const cell = cmd.params?.cell; if (!cell) return "say where they go (within three moved spaces)"; if (!boardView(state).cells[cellKey(cell)]) return "off the board"; if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone"; + // The victim WALKS those three spaces — a destination the maze's + // walls put out of reach is refused up front, not swallowed at + // resolution with the card already spent. (Rev 5; the victim may + // still slip out of range before it resolves — see onResolved.) + if ((state.config.deckRev ?? 1) >= 5 && target && + walkingDistance(state, target.position, cell) > 3) { + return "the walls put that square beyond three moved spaces"; + } return null; }, onResolved: (ctx) => { @@ -2115,7 +2191,12 @@ const CARD_EFFECTS: Record if (isLockedInPlace(ctx.state, ctx.defender.id)) return; const to = ctx.stack.params?.cell; if (!to) return; // an ambush armed without a destination fizzles - if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return; + if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) { + // The victim slipped beyond reach: the force strains and fails, + // and the table sees it fail rather than wondering. + ctx.events.push({ type: "mentalForceFizzled", attacker: ctx.attacker.id, defender: ctx.defender.id, cell: to }); + return; + } const from = ctx.defender.position; ctx.defender.position = to; ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" }); @@ -2536,7 +2617,7 @@ const CARD_EFFECTS: Record const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!other || !other.alive) return "no such living player"; if (other.id === caster.id) return "that is already your home"; - if (!gameLos(state, caster.position, other.position)) return "no line of sight to them"; + if (!gameLos(state, caster.position, other.position, caster.id)) return "no line of sight to them"; const onHome = (home: Cell) => state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length; if (onHome(caster.home) !== onHome(other.home)) { @@ -2727,7 +2808,7 @@ function emptySquareTarget( if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) { return "the warp shimmers there — nothing can form on it"; } - if (!gameLos(state, caster.position, cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight"; return cell; } @@ -2860,6 +2941,57 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId): checkVictory(state, events); } +/** A creature standing on a DIMENSIONAL WARP token steps through it like + * any walker: its commander spends one of its moves, solid stone on the + * far side refuses it, and a monster is no braver than a wizard about + * FEAR. (Monsters obey the same maze the wizards do.) */ +function doCreatureWarpStep(prev: GameState, creatureId: string): 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"); + 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 here = cellKey(creature.position); + const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here); + if (!pair) return err("it is not standing on a warp token"); + const dest = cellKey(pair.a) === here ? pair.b : pair.a; + if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone"); + if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) && + sustainedOn(state, o.id, "big-man").length > 0)) { + return err("a giant fills that square"); + } + const from = creature.position; + // FEAR holds monsters off too: "no player or monster". + if (fearRepels(state, null, from, dest)) return err("an unnatural dread stops the beast"); + creature.position = { ...dest }; + creature.movementUsed++; + const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }]; + // Touch effects on arriving in a player's square, as any step has. + 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; + if (creature.kind === "wraith" && !creature.attackUsed) { + creature.attackUsed = true; + events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); + openCreatureStack(state, creature, p, 2, "wraith"); + return { ok: true, state, events }; + } + if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) { + creature.attackUsed = true; + events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); + openCreatureStack(state, creature, p, 2, "claw"); + return { ok: true, state, events }; + } + } + return { ok: true, state, events }; +} + function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); @@ -3361,7 +3493,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number { } /** BFS steps between cells ignoring walls (teleport distance). */ -function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number { +export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number { if (cellKey(from) === cellKey(to)) return 0; const seen = new Map([[cellKey(from), 0]]); const queue: Cell[] = [from]; @@ -3370,8 +3502,17 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb const d = seen.get(cellKey(cur))!; if (d >= 8) break; // teleport range is 4; stop early for (const side of SIDES) { - const n = neighbor(cur, side); - if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue; + let n = neighbor(cur, side); + if (!board.cells[cellKey(n)]) { + // The maze wraps for walkers; it wraps for teleporters too — + // a warp mouth is one step, same as any doorway. + const w = board.warps.find( + (w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side, + ); + if (!w) continue; + n = w.to.cell; + } + if (seen.has(cellKey(n))) continue; seen.set(cellKey(n), d + 1); if (cellKey(n) === cellKey(to)) return d + 1; queue.push(n); @@ -3384,6 +3525,7 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb // Setup export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } { + config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV }; const n = config.playerIds.length; let rng = createRng(config.seed); const events: GameEvent[] = []; @@ -3669,6 +3811,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman case "wardChoice": return err("no grab is hanging on your Ward"); case "warpStep": return doWarpStep(state); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); + case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "cast": return doCast(state, command); case "setAmbush": return doSetAmbush(state, command); @@ -3747,11 +3890,60 @@ function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: if (sustainedOn(state, other.id, "fear").length === 0) continue; const d = dreadDistance(board, other.position, to); const dBefore = dreadDistance(board, other.position, from); - if (d <= 3 && d < dBefore) return true; + if (d <= 3 && d < dBefore) { + // Approaching from outside the dread is never willing. + if (dBefore > 3) return true; + // Already caged inside it: the maze's corners may force a step + // that closes the crow-flies distance — permitted only when the + // step walks the shortest way OUT of the bubble. + const eFrom = escapeSteps(state, board, other.position, from); + const eTo = escapeSteps(state, board, other.position, to); + if (!(eTo < eFrom)) return true; + } } return false; } +/** + * Walking steps (walls and shut doors respected, warp mouths crossed, + * fire braved) from `start` to the nearest cell beyond a dread bubble + * centered on `dreadFrom`. Infinity when no way out exists. + */ +function escapeSteps(state: GameState, board: AssembledBoard, dreadFrom: Cell, start: Cell): number { + if (dreadDistance(board, dreadFrom, start) > 3) return 0; + const passable = (cell: Cell, side: Side): Cell | null => { + const key = edgeKey(cell, side); + const e = board.edges[key] ?? "open"; + if (e === "wall") return null; + if (e === "door" && + !state.openDoorEdges.includes(key) && + !state.heldDoors.some((h) => h.key === key)) return null; + let n = neighbor(cell, side); + if (!board.cells[cellKey(n)]) { + const w = board.warps.find( + (w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side, + ); + if (!w) return null; + n = w.to.cell; + } + if (state.squareContents[cellKey(n)]?.kind === "stone") return null; + return n; + }; + const seen = new Set([cellKey(start)]); + const queue: [Cell, number][] = [[start, 0]]; + while (queue.length > 0) { + const [cur, d] = queue.shift()!; + for (const side of SIDES) { + const n = passable(cur, side); + if (!n || seen.has(cellKey(n))) continue; + if (dreadDistance(board, dreadFrom, n) > 3) return d + 1; + seen.add(cellKey(n)); + queue.push([n, d + 1]); + } + } + return Infinity; +} + /** A blind wizard's wasted lurch into a wall still costs a movement point. */ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult { state.turn.movementUsed++; @@ -3831,9 +4023,27 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { p.position = { ...warp.to.cell }; via = "warp"; crossedFirewall = true; - } else if (edge === "door" && doorIsOpen(state, key)) { + } else if (edge === "door" && + (doorIsOpen(state, key) || (displays(p, "master-key") && state.doorStates[key] !== "jammed"))) { + // The displayed MASTER KEY turns in every lock it meets: the walker + // passes without another cast, and the door RELOCKS BEHIND THEM at + // once — "door relocks behind you" grants no lingering opening, so + // bystanders see only a shut door (the bearer's own threshold peek + // rides the key itself). A JAMmed LOCK still refuses it. To hold a + // door open, cast the key at it as ever. + if (!doorIsOpen(state, key)) { + events.push({ type: "doorUnlocked", player: p.id, edge: parseEdgeKey(key), withCardId: "master-key" }); + } p.position = dest; via = "step"; + // "The door will relock behind you": stepping through an unlocked + // door shuts it at the walker's back unless a hand holds it open. + // (Rev 4; earlier games kept their turn-long openings.) + if ((state.config.deckRev ?? 1) >= 4 && + state.openDoorEdges.includes(key) && !state.heldDoors.some((h) => h.key === key)) { + state.openDoorEdges = state.openDoorEdges.filter((k) => k !== key); + events.push({ type: "doorsRelocked", count: 1 }); + } } else if (edge === "firewall") { // "Passing through it does four points of magical damage." Firewalls // burn even a MIST-BODY. @@ -4316,7 +4526,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult if (wall.createdBy === p.id) return err("you made it — you know exactly what it is"); if (wall.belief[p.id]) return err("your eyes have already ruled on that wall"); const events: GameEvent[] = []; - const board = openHeldDoors(state, perceivedBoard(state, p.id)); + const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id))); if (!isAdjacentToEdge(p.position, cell, side) && !losToEdge(board, p.position, cell, side)) { return err("you cannot see that wall from here"); @@ -4627,6 +4837,9 @@ function doCast(prev: GameState, cmd: Extract): Comma if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) { return err(`${def.name} is already displayed`); } + if (inHand.cardId === "master-key" && !cmd.target && caster.displayed.includes(inHand.instanceId)) { + return err("the key is already on display"); + } // Magic wands: charged on first use by the number card(s) played; one // charge per use, one use per turn; discarded when the last charge goes. @@ -4823,6 +5036,28 @@ function doCast(prev: GameState, cmd: Extract): Comma } return { ok: true, state, events: events2 }; } + // CHAOS aims at no one: cast bare, it sweeps every other living + // wizard into the pile, each offered their FULL SHIELD sit-out in + // turn order. (Full shields only exclude; nothing in the set cancels + // a zero-point storm, so the queue is the whole defense.) The older + // player-targeted form is still accepted below, so stored games + // replay unchanged. + if (inHand.cardId === "chaos" && (!cmd.target || cmd.target.kind !== "player")) { + consumeCast(state, caster, inHand, mods, 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: null, + }]; + const queue = turnOrderFrom(state, caster.id).filter((id) => + id !== caster.id && state.players.find((p) => p.id === id)!.alive); + state.chaosPending = { casterId: caster.id, excluded: [], queue }; + finishChaosIfReady(state, events); + 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); @@ -4844,7 +5079,7 @@ function doCast(prev: GameState, cmd: Extract): Comma (s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id), ); if (effect.validate) { - const problem = effect.validate(state, cmd); + const problem = effect.validate(state, cmd, caster, target); if (problem) return err(problem); } if (inHand.cardId === "waterbolt") { @@ -4932,6 +5167,7 @@ function doCast(prev: GameState, cmd: Extract): Comma kind: effect.physical ? "physical" : "spell", counters: [], waitingOn: target.id, + ...(mods.aroundCorner ? { bentCorner: true as const } : {}), }; state.lastSpellUsed[caster.id] = inHand.cardId; const events: GameEvent[] = [...wandEvents, ...preEvents]; @@ -5089,8 +5325,8 @@ function checkAmbushes( sprung = context.pickedUpTreasure === true; } else if (context.movedFrom) { if (ambush.trigger.kind === "los") { - const before = gameLos(state, owner.position, context.movedFrom); - const now = gameLos(state, owner.position, actor.position); + const before = ambushLos(state, owner, owner.position, context.movedFrom); + const now = ambushLos(state, owner, owner.position, actor.position); sprung = now && !before; } else if (ambush.trigger.kind === "near") { const dist = (c: Cell) => @@ -5102,7 +5338,7 @@ function checkAmbushes( // The committed spell must be legal right now, or the ambush stays armed. const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect; - if (fx.requiresLos && !gameLos(state, owner.position, actor.position)) continue; + if (fx.requiresLos && !ambushLos(state, owner, owner.position, actor.position)) continue; state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id); state.discard.push(ambush.via, ambush.spell, ...ambush.numbers); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 5203394..05b7a02 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -2,7 +2,7 @@ // The server sends this after every state change; clients never see the // deck order or other players' hands. -import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; +import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; import { cardDef, type CardInstance } from "./cards"; import { boardView, @@ -62,6 +62,8 @@ export interface GameView { /** Safes standing open (their combination entered this turn). */ openSafes: string[]; groundObjects: Record; + /** The rules revision this game was dealt under. */ + deckRev: number; doorStates: Record; /** Accumulated attack damage per edge (public — cracks show). */ wallDamage: Record; @@ -153,6 +155,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { groundObjects: Object.fromEntries( Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]), ), + deckRev: state.config.deckRev ?? 1, doorStates: { ...state.doorStates }, wallDamage: { ...state.wallDamage }, openDoorEdges: [...state.openDoorEdges], @@ -197,16 +200,50 @@ export function sightedCellsFor(view: GameView): Set { const [x, y] = key.split(",").map(Number) as [number, number]; if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); } + // VISIONSTONE lets its bearer see through exactly one wall or door — + // any one — so a square is also sighted if removing a single edge + // reveals it, mirroring the engine's casterLos. + if (me.displayed.some((c) => c.cardId === "visionstone")) { + const unseen = Object.keys(board.cells).filter((k) => !out.has(k)); + for (const edgeK of Object.keys(board.edges)) { + if (unseen.length === 0) break; + if ((board.edges[edgeK] ?? "open") === "open") continue; + const edges = { ...board.edges }; + delete edges[edgeK]; + const opened = { ...board, edges }; + for (let i = unseen.length - 1; i >= 0; i--) { + const [x, y] = unseen[i]!.split(",").map(Number) as [number, number]; + if (sightBetween(opened, me.position, { x, y }, blockers)) { + out.add(unseen[i]!); + unseen.splice(i, 1); + } + } + } + } return out; } /** The board-as-seen and sight blockers this view's sight rules run against. */ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record } { - // Held-open doors are open doorways to the eye. + // Held-open doors are open doorways to every eye. Beyond them, the + // viewer at a door's threshold may pull it open and peek (rules rev 2): + // lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in + // hand — mirroring the engine's doorsAjar. let board = view.board; - if (view.heldDoorEdges.length > 0) { + const openKeys = new Set(view.heldDoorEdges); + const me = view.players.find((p) => p.id === view.you); + if (view.deckRev >= 2 && me?.alive) { + const carriesKey = view.yourHand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key"); + for (const side of SIDES) { + const key = edgeKey(me.position, side); + if (board.edges[key] !== "door") continue; + const workable = carriesKey && view.doorStates[key] !== "jammed"; + if (workable || view.doorStates[key] === "removed" || view.openDoorEdges.includes(key)) openKeys.add(key); + } + } + if (openKeys.size > 0) { const edges = { ...board.edges }; - for (const k of view.heldDoorEdges) delete edges[k]; + for (const k of openKeys) delete edges[k]; board = { ...board, edges }; } const blockers: Record = {}; @@ -259,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean { */ export function stackSightTrace( view: GameView, -): { from: Cell; to: Cell; trace: SightTrace } | null { +): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null { const stack = view.stack; if (!stack || stack.creatureId) return null; if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null; @@ -268,7 +305,23 @@ export function stackSightTrace( if (!a || !d) return null; if (a.position.x === d.position.x && a.position.y === d.position.y) return null; const trace = traceSightFor(view, a.position, d.position); - return trace ? { from: a.position, to: d.position, trace } : null; + if (trace) return { from: a.position, to: d.position, trace }; + // AROUND THE CORNER: no straight line exists — find a middle square both + // ends can see and draw the sight leg by leg, so the table can audit the + // needle instead of doubting it. + if (stack.bentCorner) { + for (const key of Object.keys(view.board.cells)) { + const [mx, my] = key.split(",").map(Number) as [number, number]; + const mid = { x: mx, y: my }; + if ((mid.x === a.position.x && mid.y === a.position.y) || + (mid.x === d.position.x && mid.y === d.position.y)) continue; + const leg1 = traceSightFor(view, a.position, mid); + if (!leg1) continue; + const leg2 = traceSightFor(view, mid, d.position); + if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } }; + } + } + return null; } const CREATION_CARD_IDS = new Set([ diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 938381b..43dfc10 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -14,6 +14,7 @@ import { pushSustained } from "./helpers"; /** Whose input does the maze want right now? */ function actingSeat(state: GameState): PlayerId { return ( + state.wardPending?.ownerId ?? state.stack?.waitingOn ?? state.pendingDiscard ?? state.chaosPending?.queue[0] ?? @@ -735,3 +736,221 @@ describe("the clockwork flees the dread", () => { } }); }); + +describe("the thief-chase holds one goal per turn", () => { + it("a clockwork beside its thief never shuttles on and off their square", () => { + let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] }); + // Round 2, thief's turn burned; the bot acts with a full allowance. + while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const thief = state.players.find((p) => p.id === "thief")!; + const bot = state.players.find((p) => p.id === "bot")!; + // The thief carries the bot's gold and stands one step away. + const mine = state.treasures.find((t) => t.owner === "bot")!; + mine.carriedBy = "thief"; + mine.position = null; + thief.position = { x: 2, y: 4 }; + bot.position = { x: 2, y: 5 }; + state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open"; + // A number card and no attacks: the blow the chase serves cannot land. + bot.hand = []; + bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never); + const visited = [cellKey(bot.position)]; + for (let guard = 0; guard < 40; guard++) { + const view = viewFor(state, "bot"); + const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage"); + if (cmd.type === "endTurn") break; + const r = applyCommand(state, "bot", cmd); + if (!r.ok) break; + state = r.state; + const at = cellKey(state.players.find((p) => p.id === "bot")!.position); + if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at); + } + // No step may return to the square just departed: A-B-A is the shuttle. + for (let i = 2; i < visited.length; i++) { + expect(visited[i]).not.toBe(visited[i - 2]); + } + }); +}); + +describe("the archmage's sharpened instincts", () => { + function botTurn(seed = 42) { + let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] }); + while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + return state; + } + + it("a MASTER KEY drawn goes straight on display", () => { + const state = botTurn(); + const bot = state.players.find((p) => p.id === "bot")!; + bot.hand.push({ cardId: "master-key", instanceId: "MK" } as never); + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ type: "cast", instanceId: "MK" }); + }); + + it("a lethal small blow is countered, thrift be damned", () => { + let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); + while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "foe") { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const foe = state.players.find((p) => p.id === "foe")!; + const bot = state.players.find((p) => p.id === "bot")!; + bot.position = { ...foe.position }; + bot.life = 2; + bot.hand = [{ cardId: "full-shield", instanceId: "FS" } as never]; + foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never); + const r = applyCommand(state, "foe", { + type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" }, + }); + if (!r.ok) throw new Error(r.error); + // Base fireball, 2 points: below every thrift threshold, but fatal at + // 2 life — the shield comes out. + const cmd = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ type: "counteract", instanceId: "FS" }); + }); + + it("no number is hoarded against the delivery that wins the game", () => { + let state = botTurn(); + const bot = state.players.find((p) => p.id === "bot")!; + const foe = state.players.find((p) => p.id === "foe")!; + // One of the foe's treasures already rests at the bot's home; the bot + // carries the second, four squares out with three legs — only the + // number bridges it home this turn. + const gold = state.treasures.filter((t) => t.owner === "foe"); + expect(gold.length).toBeGreaterThanOrEqual(2); + gold[0]!.position = { ...bot.home }; + gold[0]!.carriedBy = null; + gold[1]!.carriedBy = "bot"; + gold[1]!.position = null; + bot.carriedTreasureId = gold[1]!.id; + foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard + bot.hand = [ + { cardId: "fireball", instanceId: "FB" } as never, + { cardId: "number-3", instanceId: "N3" } as never, + ]; + // Stand the bot a straight, open four squares from home. + const home = bot.home; + const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d })); + if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return; + bot.position = { ...column[4]! }; + for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open"; + for (let guard = 0; guard < 12; guard++) { + const view = viewFor(state, "bot"); + const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage"); + if (cmd.type === "playNumberForMovement") return; // the chest opened for the win + if (cmd.type === "endTurn") break; + const r = applyCommand(state, "bot", cmd); + if (!r.ok) break; + state = r.state; + if (state.phase !== "playing") return; // delivered and won outright + } + throw new Error("the clockwork hoarded its number instead of winning"); + }); +}); + +describe("lessons from the human duels", () => { + function rig(players = ["foe", "bot"]) { + let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] }); + while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + return state; + } + + it("a raider at the stocked bank's gates pulls the clockwork home", () => { + const state = rig(); + const bot = state.players.find((p) => p.id === "bot")!; + const foe = state.players.find((p) => p.id === "foe")!; + const gold = state.treasures.find((t) => t.owner === "foe")!; + gold.position = { ...bot.home }; + gold.carriedBy = null; + foe.position = { x: bot.home.x, y: bot.home.y + 1 }; + bot.position = { ...foe.home }; + bot.hand = []; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + // Whatever step it picks, the march must be TOWARD home, not the gold map. + expect(cmd?.type).toBe("move"); + }); + + it("carrying with home a blink away, it teleports the delivery", () => { + const state = rig(); + const bot = state.players.find((p) => p.id === "bot")!; + const prize = state.treasures.find((t) => t.owner === "foe")!; + prize.carriedBy = "bot"; + prize.position = null; + bot.carriedTreasureId = prize.id; + // Two squares from home as the spell flies, but walled off on foot. + bot.position = { x: bot.home.x, y: bot.home.y + 2 }; + for (const side of ["N", "S", "E", "W"] as const) { + state.edgeOverrides[edgeKey(bot.position, side)] = "wall"; + } + bot.hand = [{ cardId: "teleport", instanceId: "TP" } as never]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } }); + }); + + it("a would-win carrier eats the lightning, not the fireball", () => { + let state = rig(); + const bot = state.players.find((p) => p.id === "bot")!; + const foe = state.players.find((p) => p.id === "foe")!; + // Foe has one banked and carries the second: one delivery from winning. + const banked = state.treasures.find((t) => t.owner === "bot")!; + banked.position = { ...foe.home }; + const carried = state.treasures.filter((t) => t.owner === "bot")[1]; + if (!carried) return; + carried.carriedBy = "foe"; + carried.position = null; + foe.carriedTreasureId = carried.id; + foe.position = { ...bot.position }; + bot.hand = [ + { cardId: "fireball", instanceId: "FB" } as never, + { cardId: "lightning-blast", instanceId: "LB" } as never, + { cardId: "number-4", instanceId: "N4" } as never, + ]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" }); + }); +}); + +describe("the denial planner offers only castable blocks", () => { + it("tacks at range are never proposed — the RNRX coma", () => { + // Room RNRX froze Automaton II for three turns: pathDenial proposed + // scattering tacks four squares away, the engine refused ("you must + // be adjacent"), and the refusal-fallback loop read as a coma. + let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); + while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const bot = state.players.find((p) => p.id === "bot")!; + const foe = state.players.find((p) => p.id === "foe")!; + // The bot's floor gold with a raider closing on it, and only TACKS + // in hand to deny the road — from a stand-off distance. + const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!; + foe.position = { ...gold.position! }; + bot.position = { ...bot.home }; + bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" } as never]; + for (let guard = 0; guard < 6; guard++) { + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + if (!cmd) break; + const r = applyCommand(state, "bot", cmd); + // Whatever the brain proposes, the engine must accept it. + expect(r.ok, `refused: ${JSON.stringify(cmd)} — ${!r.ok ? r.error : ""}`).toBe(true); + if (!r.ok) break; + state = r.state; + if (cmd.type === "endTurn") break; + } + }); +}); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index eed75a3..fcf1be4 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game"; import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board"; import type { CardInstance } from "../src/cards"; -import { viewFor } from "../src/view"; +import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; /** Test surgery: put a specific card into a player's hand (swapping one out). */ @@ -402,6 +402,35 @@ describe("the ward window and chaos shields", () => { expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); }); + it("chaos casts bare — no target, every other wizard queued for a shield", () => { + let { state } = threeGame(); + state = toRound2(state); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + const caster = activePlayer(state); + const others = state.players.filter((p) => p.id !== caster.id).map((p) => p.id); + const chaos = giveCard(state, caster.id, "chaos", "C", 0); + const shielder = others[0]!; + giveCard(state, shielder, "full-shield", "S", 0); + const shielderHand = () => state.players.find((p) => p.id === shielder)!.hand.map((c) => c.instanceId).sort(); + const kept = shielderHand().filter((id) => id !== "full-shield#S").sort(); + + state = must(state, caster.id, { type: "cast", instanceId: chaos.instanceId }); + // No stack: the queue opens at once, in turn order from the caster. + expect(state.stack).toBeNull(); + expect(state.chaosPending?.queue.length).toBe(2); + const first = state.chaosPending!.queue[0]!; + state = first === shielder + ? must(state, first, { type: "counteract", instanceId: "full-shield#S" }) + : must(state, first, { type: "pass" }); + const second = state.chaosPending!.queue[0]!; + state = second === shielder + ? must(state, second, { type: "counteract", instanceId: "full-shield#S" }) + : must(state, second, { type: "pass" }); + expect(state.chaosPending).toBeNull(); + // The shielded hand rode out the storm untouched. + expect(shielderHand()).toEqual(kept); + }); + it("chaos: bystanders may shield out, reflections are refused", () => { let { state } = threeGame(); state = toRound2(state); @@ -500,6 +529,83 @@ describe("zero-damage utility attacks", () => { }); }); +describe("the VISIONSTONE pierces one wall", () => { + function stoneRig() { + let { state } = newGame(); + state = toRound2(state); + const a = activePlayer(state); + const b = state.players.find((p) => p.id !== a.id)!; + a.position = { x: 2, y: 4 }; + b.position = { x: 2, y: 5 }; + state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "wall"; + return { state, a, b }; + } + + it("its bearer casts an attack straight through the wall", () => { + const { state, a, b } = stoneRig(); + const fb = giveCard(state, a.id, "fireball", "FB", 1); + const refused = applyCommand(state, a.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id }, + }); + expect(refused.ok).toBe(false); + const stone = giveCard(state, a.id, "visionstone", "VS"); + a.displayed.push(stone.instanceId); + const cast = applyCommand(state, a.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id }, + }); + expect(cast.ok).toBe(true); + }); + + it("its bearer plants a THORNBUSH through the wall", () => { + const { state, a } = stoneRig(); + // The square past the wall must be bare ground for a creation. + const cell = { x: 2, y: 5 }; + for (const p of state.players) if (cellKey(p.position) === cellKey(cell)) p.position = { x: 0, y: 0 }; + const bush = giveCard(state, a.id, "thornbush", "TB"); + const refused = applyCommand(state, a.id, { + type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell }, + }); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.error).toContain("line of sight"); + const stone = giveCard(state, a.id, "visionstone", "VS", 1); + a.displayed.push(stone.instanceId); + const cast = applyCommand(state, a.id, { + type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell }, + }); + expect(cast.ok).toBe(true); + }); + + it("the client's sighted squares agree with the stone", () => { + const { state, a, b } = stoneRig(); + expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false); + const stone = giveCard(state, a.id, "visionstone", "VS"); + a.displayed.push(stone.instanceId); + expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(true); + }); +}); + +describe("teleport wraps the maze's rim", () => { + it("four spaces counted through a warp mouth reach the far side", () => { + let { state } = newGame(); + state = toRound2(state); + // Stand the active wizard at a wraparound mouth; the paired cell is + // ONE teleport step away, exactly as it is one walking step. + const active = activePlayer(state); + const warp = boardView(state).warps[0]!; + active.position = { ...warp.from.cell }; + const tp = giveCard(state, active.id, "teleport", "TP", 0); + const r = applyCommand(state, active.id, { + type: "cast", instanceId: tp.instanceId, + target: { kind: "cell", cell: { ...warp.to.cell } }, + }); + expect(r.ok).toBe(true); + if (r.ok) { + const after = r.state.players.find((p) => p.id === active.id)!; + expect(cellKey(after.position)).toBe(cellKey(warp.to.cell)); + } + }); +}); + describe("teleport as a counteraction", () => { it("the attack has no chance of hitting you — you are simply elsewhere", () => { let { state } = newGame(); @@ -1034,3 +1140,58 @@ describe("stone dead counts only the stones in play", () => { expect(state.players.find((p) => p.id === defender)!.life).toBe(12); }); }); + +describe("MENTAL FORCE respects the victim's three walked spaces", () => { + it("a destination beyond the walls is refused up front, card kept", () => { + let { state } = newGame(); + state = toRound2(state); + const caster = activePlayer(state); + const victim = state.players.find((p) => p.id !== caster.id)!; + caster.position = { x: 0, y: 0 }; + victim.position = { x: 2, y: 4 }; + // Seal the victim into their square: every walked space is out of + // reach, so ANY other destination is beyond three moved spaces. + for (const side of SIDES) { + state.edgeOverrides[edgeKey(victim.position, side)] = "wall"; + } + const mf = giveCard(state, caster.id, "mental-force", "MF"); + const far = { x: 4, y: 9 }; + const refused = applyCommand(state, caster.id, { + type: "cast", instanceId: mf.instanceId, + target: { kind: "player", playerId: victim.id }, params: { cell: far }, + }); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.error).toContain("three moved spaces"); + // The card is still in hand — nothing was spent on the refusal. + expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true); + }); +}); + +describe("the bent sight-trace shows AROUND THE CORNER's legs", () => { + it("a bentCorner stack yields two auditable legs through a midpoint", () => { + let { state } = newGame(); + state = toRound2(state); + const a = activePlayer(state); + const d = state.players.find((p) => p.id !== a.id)!; + // Diagonal neighbors: center-to-center sight grazes the shared corner + // (blocked, strict reading), but the legs through (3,2) are opened. + a.position = { x: 2, y: 2 }; + d.position = { x: 3, y: 3 }; + state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "E")] = "open"; + state.edgeOverrides[edgeKey({ x: 3, y: 2 }, "S")] = "open"; + state.stack = { + attackerId: a.id, defenderId: d.id, + attackCard: { instanceId: "fireball#T", cardId: "fireball" }, + numberValue: null, amplifyFactor: 1, extendFactor: 1, + powerAttackPoints: 0, params: null, kind: "spell", + counters: [], waitingOn: d.id, bentCorner: true, + }; + const traced = stackSightTrace(viewFor(state, d.id)); + expect(traced).not.toBeNull(); + if (traced) { + // Whether sight ran straight (free-angle found a gap) or bent, the + // overlay has something to draw; a bend names its middle square. + if (traced.bend) expect(traced.bend.mid).toBeDefined(); + } + }); +}); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 424e6c8..2b17ece 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -720,4 +720,70 @@ describe("fear holds off monsters and unwilling feet alike", () => { expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toContain("dread"); }); + + it("a wizard caged inside the dread may round a corner to escape", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); + const a = state.players.find((p) => p.id === "a")!; + const b = state.players.find((p) => p.id === "b")!; + pushSustained(state, { + id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b", + remainingTurns: 5, data: {}, + }); + // b radiates dread from (2,5). a stands in a dead-end pocket at + // (2,3): walls on three sides, so the only way out steps SOUTH — + // closer to b — before the corridor east leads clear of the dread. + b.position = { x: 2, y: 5 }; + a.position = { x: 2, y: 3 }; + for (const side of ["N", "E", "W"] as const) { + state.edgeOverrides[edgeKey({ x: 2, y: 3 }, side)] = "wall"; + } + state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "S")] = "open"; + state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "E")] = "open"; + state.edgeOverrides[edgeKey({ x: 3, y: 4 }, "E")] = "open"; + state.edgeOverrides[edgeKey({ x: 4, y: 4 }, "N")] = "open"; + while (state.players[state.turn.activeIndex]!.id !== "a") { + const r0 = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); + if (!r0.ok) throw new Error(r0.error); + state = r0.state; + } + // The closer step is the only way out: permitted. + const r = applyCommand(state, "a", { type: "move", direction: "S" }); + if (!r.ok) throw new Error("escape step refused: " + r.error); + expect(r.ok).toBe(true); + }); +}); + +describe("creatures and the dimensional warp", () => { + it("a commanded troll steps through the warp tokens", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); + const you = state.players[state.turn.activeIndex]!.id; + state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } }); + state.creatures.push({ + id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 }, + life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, + justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, + } as never); + const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" }); + expect(r.ok).toBe(true); + if (r.ok) { + const troll = r.state.creatures.find((c) => c.id === "troll-w")!; + expect(cellKey(troll.position)).toBe(cellKey({ x: 3, y: 8 })); + expect(troll.movementUsed).toBe(1); + } + }); + + it("solid stone on the far side refuses the beast", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); + const you = state.players[state.turn.activeIndex]!.id; + state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } }); + state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" }; + state.creatures.push({ + id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 }, + life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, + justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, + } as never); + const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("stone"); + }); }); diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index 5dc421a..0157608 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game"; -import { cellKey, edgeKey, neighbor, type Side } from "../src/board"; +import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; @@ -111,7 +111,7 @@ describe("doors", () => { throw new Error("no door on this board"); } - it("pick lock opens an adjacent door until end of turn", () => { + it("pick lock opens an adjacent door — and it relocks behind the walker", () => { let { state } = newGame(); const me = activePlayer(state); const door = findDoor(state); @@ -130,9 +130,8 @@ describe("doors", () => { state = must(state, me.id, { type: "move", direction: dir }); expect(cellKey(activePlayer(state).position)).toBe(cellKey(other)); - // Relocks when the turn ends. - state = must(state, me.id, { type: "endTurn", draw: 0 }); - state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + // "It will relock behind you": shut at the walker's back at once — + // the way back is barred without another pick. expect(state.openDoorEdges.length).toBe(0); expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false); }); @@ -445,7 +444,7 @@ describe("a held door is an open doorway to the eye", () => { expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore); }); - it("an unheld unlocked door still blocks sight", () => { + it("an unlocked door is an open doorway to the wizard at its threshold", () => { let { state, cell, side, holder, pursuer } = sightRig(); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { @@ -453,9 +452,116 @@ describe("a held door is an open doorway to the eye", () => { target: { kind: "edge", cell, side }, }); const fb = giveCard(state, holder, "fireball", "FB", 1); - const refused = applyCommand(state, holder, { + const cast = applyCommand(state, holder, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, }); + expect(cast.ok).toBe(true); + }); + + it("a lock REMOVED lets any wizard beside the door peek through", () => { + let { state, cell, side, holder, pursuer } = sightRig(); + const rm = giveCard(state, holder, "remove-lock"); + state = must(state, holder, { + type: "cast", instanceId: rm.instanceId, + target: { kind: "edge", cell, side }, + }); + const fb = giveCard(state, holder, "fireball", "FB", 1); + const cast = applyCommand(state, holder, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, + }); + expect(cast.ok).toBe(true); + }); + + it("PICK LOCK in hand is enough to ease a locked door open a crack", () => { + let { state, cell, side, holder, pursuer } = sightRig(); + giveCard(state, holder, "pick-lock"); + const fb = giveCard(state, holder, "fireball", "FB", 1); + const cast = applyCommand(state, holder, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, + }); + expect(cast.ok).toBe(true); + }); + + it("the wizard down the hallway sees no further than the shut door", () => { + let { state, cell, side, holder, pursuer } = sightRig(); + // Pull the pursuer one square further down the hall, so the door is + // no longer on their threshold; clear their path to the doorway. + const near = neighbor(cell, side); + const far = neighbor(near, side); + if (!boardView(state).cells[cellKey(far)]) return; // the maze ends here; geometry unavailable + state.players.find((p) => p.id === pursuer)!.position = far; + state.edgeOverrides[edgeKey(near, side)] = "open"; + const rm = giveCard(state, holder, "remove-lock"); + state = must(state, holder, { + type: "cast", instanceId: rm.instanceId, + target: { kind: "edge", cell, side }, + }); + state = must(state, holder, { type: "endTurn", draw: 0 }); + const fb = giveCard(state, pursuer, "fireball", "FB", 1); + const refused = applyCommand(state, pursuer, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: holder }, + }); expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.error).toContain("line of sight"); + }); +}); + +describe("the displayed MASTER KEY", () => { + function keyRig() { + let { state } = createGame({ playerIds: ["keeper", "watcher"], seed: 42, sets: ["basic"] }); + state = toRound2(state); + const view = boardView(state); + for (const [key, edge] of Object.entries(view.edges)) { + if (edge !== "door") continue; + const [kind, coords] = key.split(":") as [string, string]; + const [x, y] = coords.split(",").map(Number) as [number, number]; + const cell = { x, y }; + const side = kind === "V" ? ("E" as Side) : ("S" as Side); + const keeper = activePlayer(state); + keeper.position = { ...cell }; + return { state, cell, side, key, keeper }; + } + throw new Error("setup: seed 42 grew a maze with no doors"); + } + + it("cast bare, the key goes on display — once", () => { + const { state, keeper } = keyRig(); + const mk = giveCard(state, keeper.id, "master-key", "MK"); + const cast = applyCommand(state, keeper.id, { type: "cast", instanceId: mk.instanceId }); + expect(cast.ok).toBe(true); + if (cast.ok) { + const after = cast.state.players.find((p) => p.id === keeper.id)!; + expect(after.displayed).toContain(mk.instanceId); + expect(after.hand.some((c) => c.instanceId === mk.instanceId)).toBe(true); + const again = applyCommand(cast.state, keeper.id, { type: "cast", instanceId: mk.instanceId }); + expect(again.ok).toBe(false); + } + }); + + it("its bearer walks through locked doors, which relock at their back", () => { + const { state, cell, side, key, keeper } = keyRig(); + const mk = giveCard(state, keeper.id, "master-key", "MK"); + keeper.displayed.push(mk.instanceId); + const r = applyCommand(state, keeper.id, { type: "move", direction: side }); + expect(r.ok).toBe(true); + if (r.ok) { + const after = r.state.players.find((p) => p.id === keeper.id)!; + expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side))); + // "Door relocks behind you": no lingering opening for bystanders. + expect(r.state.openDoorEdges).not.toContain(key); + expect(r.events.some((e) => e.type === "doorUnlocked")).toBe(true); + // The key turns again on the way back. + const back = applyCommand(r.state, keeper.id, { type: "move", direction: opposite(side) }); + expect(back.ok).toBe(true); + } + }); + + it("a JAMmed LOCK refuses even the master key", () => { + const { state, side, key, keeper } = keyRig(); + const mk = giveCard(state, keeper.id, "master-key", "MK"); + keeper.displayed.push(mk.instanceId); + state.doorStates[key] = "jammed"; + const r = applyCommand(state, keeper.id, { type: "move", direction: side }); + expect(r.ok).toBe(false); }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 784bae5..ae9c05e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -57,6 +57,8 @@ import { summarize, viewForPlayer, type Room, + kickSeat, + abandonRoom, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; import { getShare, loadShares, mintShare } from "./shares"; @@ -394,6 +396,16 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string } } } +/** Spoken when a clockwork's chosen command was refused by the engine — + * the table sees a stumble instead of an unexplained idle turn. */ +const HESITATION_LINES = [ + "RECALCULATING.", + "TACTICAL PAUSE. INTENTIONAL. PROBABLY.", + "THE MAZE REFUSES MY GENIUS.", + "ERROR LOGGED. DIGNITY INTACT.", + "I MEANT TO DO THAT.", +]; + const pumping = new Set(); function runBots(room: Room): void { if (pumping.has(room.id)) return; @@ -408,6 +420,14 @@ function runBots(room: Room): void { broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); // The game's end unmasks the mystery machines in the roster. if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room)); + // A refused brain-choice becomes a visible stumble, in character — + // an idle bot turn should read as hesitation, never as nothing. + if (step.hesitated) { + const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!); + if (!("error" in said)) { + broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at })); + } + } botRemark(room, step.seat, step.events as { type: string }[]); setTimeout(tick, BOT_STEP_MS); }; @@ -526,6 +546,38 @@ wss.on("connection", (socket) => { session.token = null; break; } + case "kickSeat": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const problem = kickSeat(room, session.playerId, String(msg.name ?? "")); + if (problem) return send(socket, { type: "error", message: problem }); + // A kicked live socket is set adrift so it cannot act on a seat it lost. + for (const other of sessions) { + if (other.roomId === room.id && other.playerId === msg.name) { + other.playerId = null; + other.roomId = null; + other.token = null; + send(other.socket, { type: "kicked", roomId: room.id }); + } + } + broadcastRoomState(room); + break; + } + case "abandonRoom": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const problem = abandonRoom(room, session.playerId); + if (problem) return send(socket, { type: "error", message: problem }); + for (const other of sessions) { + if (other.roomId === room.id) { + other.playerId = null; + other.roomId = null; + other.token = null; + send(other.socket, { type: "roomAbandoned", roomId: room.id }); + } + } + break; + } case "addBot": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 76974a5..e3e0e23 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -20,8 +20,9 @@ import { type GameState, type GameView, type PlayerId, + CURRENT_RULES_REV, } from "@wizwar/engine"; -import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; +import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { recordRoom } from "./stats"; export interface LoggedCommand { @@ -56,7 +57,7 @@ const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). * A rules change while games are live must bump this and gate the engine; * local hotseat games ride the engine's default and follow in lockstep. */ -const RULES_REV = 1; +const RULES_REV = CURRENT_RULES_REV; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -152,6 +153,33 @@ export function joinRoom( return { token: fresh }; } +/** The host shows an unclaimed (or unwanted) seat the door — lobby only: + * once the deal happens, every seat is a player in the game's record. */ +export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null { + if (byId !== room.hostId) return "only the host may clear a seat"; + if (room.state) return "the game has started — seats are settled"; + if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead"; + if (!room.players.includes(name)) return "no such seat"; + room.players = room.players.filter((p) => p !== name); + room.tokens.delete(name); + room.bots.delete(name); + room.colorChoices.delete(name); + appendLine(room.id, { kind: "kick", name }); + recordRoom(room); + return null; +} + +/** The host dissolves the room: a lobby that never dealt, or a finished + * game done being remembered. The ledger is archived, never deleted. */ +export function abandonRoom(room: Room, byId: PlayerId): string | null { + if (byId !== room.hostId) return "only the host may abandon the room"; + if (room.state && room.state.phase === "playing") return "the game is still being played"; + appendLine(room.id, { kind: "abandon", by: byId, at: new Date().toISOString() }); + archiveRoomFile(room.id); + rooms.delete(room.id); + return null; +} + /** Everyone gets their chosen standee; the undecided get the first free one. */ export function resolveColors(room: Room): number[] { const taken = new Set(); @@ -290,17 +318,28 @@ function actingSeat(room: Room): PlayerId | null { * One automaton command, if the maze is waiting on clockwork. Null when a * human holds the floor (or the game is over, or the clockwork is wedged). */ -export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null { +export function driveOneAutomaton( + room: Room, +): { seat: PlayerId; events: GameEvent[]; hesitated?: { refused: Command; error: string } } | null { const seat = actingSeat(room); if (!seat || !room.bots.has(seat)) return null; const view = viewFor(room.state!, seat); const bot = room.bots.get(seat); const chosen = automatonCommand(view, bot?.style, bot?.tier); + let hesitated: { refused: Command; error: string } | undefined; let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier)); if ("error" in r) { // A refused choice retries with the fallback — unless the fallback IS // what just failed — then burns down the ladder to endTurn and pass. - if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier)); + // The refusal is remembered: a brain whose choice the engine rejects + // is a bug in the brain, and the table deserves to see the stumble + // rather than an unexplained idle turn (the RNRX coma lesson). + if (chosen) { + hesitated = { refused: chosen, error: r.error }; + console.warn( + `automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`); + r = runCommand(room, seat, automatonFallback(view, bot?.tier)); + } if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 }); if ("error" in r) r = runCommand(room, seat, { type: "pass" }); if ("error" in r) { @@ -308,7 +347,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve return null; } } - return { seat, events: r.events }; + return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) }; } export interface GameSummary { @@ -600,8 +639,14 @@ export function loadPersistedRooms(): void { chat: [], bots: new Map(), }; + if (lines.some((l) => l.kind === "abandon")) continue; for (const line of lines.slice(1)) { - if (line.kind === "join") { + if (line.kind === "kick") { + room.players = room.players.filter((p) => p !== line.name); + room.tokens.delete(line.name); + room.bots.delete(line.name); + room.colorChoices.delete(line.name); + } else if (line.kind === "join") { if (line.bot) { room.players.push(line.name); room.bots.set(line.name, { diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 76cebde..1ccf171 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -3,7 +3,7 @@ // command. Because the engine is deterministic, replaying a file rebuilds // the exact game state — server restarts lose nothing. -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs"; import { join } from "node:path"; export interface RoomMetaLine { @@ -58,7 +58,19 @@ export interface ChatLine { at: string; } -export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine; +export interface KickLine { + kind: "kick"; + /** The seat the host removed from an unstarted room. */ + name: string; +} + +export interface AbandonLine { + kind: "abandon"; + by: string; + at: string; +} + +export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine; const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); @@ -103,3 +115,13 @@ export function readAllRooms(): Map { } return rooms; } + +/** Retire an abandoned room's ledger to the graveyard — never deleted, + * only moved out of the living rooms directory. */ +export function archiveRoomFile(roomId: string): void { + const src = join(DATA_DIR, `${roomId}.jsonl`); + if (!existsSync(src)) return; + const graveyard = join(DATA_DIR, "..", "rooms-abandoned"); + mkdirSync(graveyard, { recursive: true }); + renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`)); +} diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 6cadd19..13c5fe0 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -342,7 +342,7 @@ "wall-of-fire": "click a corridor line for the fire", "waterwall": "click a corridor line — the wave collapses at once", "pick-lock": "click a locked door", - "master-key": "click a locked door", + "master-key": "click a locked door — or Display it once and walk through every lock", "jam-lock": "click a door to jam its lock solid", "remove-lock": "click a door to strip its lock for good", "create-door": "click a wall for the new door", @@ -720,6 +720,15 @@ dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side }); return; } + // Standing on a DIMENSIONAL WARP token: clicking the paired token + // steps it through. + const dw = view.dimWarps.find((d) => + (cellKey(d.a) === cellKey(creature.position) && cellKey(d.b) === cellKey(cell)) || + (cellKey(d.b) === cellKey(creature.position) && cellKey(d.a) === cellKey(cell))); + if (dw) { + dispatch({ type: "creatureWarpStep", creatureId: selectedCreature }); + return; + } } selectedCreature = null; return; @@ -1256,6 +1265,7 @@
Wiz-War sixth edition + {__BUILD_STAMP__} {#if local.active} hotseat @@ -1811,6 +1821,10 @@ {/if} {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""} + {#if net.you === net.hostId && p !== net.hostId} + + {/if} {/each} @@ -1834,6 +1848,8 @@ {/each} {#if net.you === net.hostId} + {#if net.players.length < 6} ⚙ seat a @@ -2219,6 +2235,10 @@ {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} {/if} + {#if selectedCard?.cardId === "master-key" && isYourTurn && + !me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)} + + {/if} {#if selectedCard?.cardId === "rotate-sector"} — click the sector @@ -2463,6 +2483,14 @@ text-transform: uppercase; color: #8d8672; } + .kick { margin-left: 0.4rem; color: #a04545; } + .abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; } + .mast-version { + font-family: "Courier Prime", monospace; + font-size: 0.65rem; + letter-spacing: 0.06em; + color: #6b6454; + } .mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; } .mast-room b { color: #e9e1cb; letter-spacing: 0.12em; } .mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; } diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index cccd20e..3f606b1 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -68,7 +68,7 @@ effects?: BoardFx[] | null; /** The sight line an attack in progress traveled — proof against "how * can he even see me?", drawn leg by leg through any warp mouth. */ - sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null; + sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null; } = $props(); @@ -390,14 +390,20 @@ x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3} width={CELL * 0.4} height={CELL * 0.4} preserveAspectRatio="xMidYMid slice" - class="token-art small" + class="token-art small peekable" + role="button" tabindex="-1" + onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }} + onkeydown={() => {}} > {o.cardId} {:else} { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }} + onkeydown={() => {}} > {o.cardId} @@ -661,7 +667,14 @@ {#if sightTrace} - + {#if sightTrace.bend} + + + + {:else} + + {/if} {/if}