Merge branch 'main' into first-person-play

This commit is contained in:
Eric Wagoner
2026-08-26 12:13:13 -04:00
17 changed files with 1697 additions and 146 deletions
+140
View File
@@ -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 <ROOM> Claude # take a seat (token saved)
node tools/claude-seat.mjs view # board + hand + last events
node tools/claude-seat.mjs do '<command json>' # 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".
+228 -47
View File
@@ -9,6 +9,7 @@
import { cardDef, type CardInstance } from "./cards"; import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
import { bentSightFor, sightedCellsFor, type GameView } from "./view"; import { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game"; import type { AmbushTrigger, Command, PlayerId } from "./game";
export type AutomatonStyle = "hunter" | "berserker" | "worrier"; 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! }); 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; if (threats.length === 0) return null;
const sighted = sightedCellsFor(view); 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. // 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( consider(
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } }, { type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
{ avoidCell: cellKey(b) }, { avoidCell: cellKey(b) },
@@ -597,7 +614,7 @@ function pathToward(
const hazard = view.squareContents[k]?.kind; const hazard = view.squareContents[k]?.kind;
if (!opts.throughHazards && if (!opts.throughHazards &&
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime")) continue; hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
if (opts.avoidNearEnemies && nearEnemy(to)) continue; if (opts.avoidNearEnemies && nearEnemy(to)) continue;
next.push(to); next.push(to);
} }
@@ -630,7 +647,16 @@ function treasureGoals(view: GameView): Set<string> {
} }
for (const t of view.treasures) { for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue; 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; if (cellKey(t.position) === cellKey(self.home)) continue;
goals.add(cellKey(t.position)); 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); 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. */ /** The wizard making off with MY gold, if any. */
function thiefOfMine(view: GameView) { function thiefOfMine(view: GameView) {
const carriers = new Set( const carriers = new Set(
@@ -722,7 +757,16 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
return { type: "pass" }; 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 attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null; const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false; 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) ? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) : (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1; : 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") { if (stack.kind === "spell") {
// REVERSE eats points; a pure duration offers it nothing. // REVERSE eats points; a pure duration offers it nothing.
const reverse = find("reverse"); 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. // ABSORB SPELL steals the good ones for later.
const absorbSpell = find("absorb-spell"); 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"); 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 }; return { type: "counteract", instanceId: shield.instanceId };
} }
const reflect = find("full-reflection"); 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. // A curse in flight is best refused at the door.
if (affliction) { if (affliction) {
const cleanse = find("remove-curse"); const cleanse = find("remove-curse");
@@ -777,9 +826,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
const permanentCurse = affliction && const permanentCurse = affliction &&
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot"); (attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
const absorb = find("absorb"); 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"); 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 // The berserker shares its pain out of spite — but only pain: a
// damage-less curse gives EMPATHY nothing to mirror. // damage-less curse gives EMPATHY nothing to mirror.
const empathy = find("empathy"); 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. // Nothing to block with: teleport clear of the big ones.
const tp = find("teleport"); const tp = find("teleport");
if (tp && incoming >= 4 - flinch) { if (tp && weight >= 4 - flinch) {
const out = escapeCell(view, me(view).position); const out = escapeCell(view, me(view).position);
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } }; 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. // Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
const vanish = find("invisible"); const vanish = find("invisible");
if (vanish && incoming >= 3 - flinch) { if (vanish && weight >= 3 - flinch) {
const num = numbersInHand(view)[0]; const num = numbersInHand(view)[0];
return { return {
type: "counteract", instanceId: vanish.instanceId, 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. */ /** 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 { function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
const numbers = numbersInHand(view); 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 biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0; const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
const target = view.players.find((p) => p.id === targetId)!; const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position); const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = tier.amplify ? inHand(view, "amplify") : undefined; 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) { for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId]; const atk = ATTACKS[c.cardId];
if (!atk) continue; if (!atk) continue;
@@ -831,25 +914,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
const isWand = c.cardId === "blaster-wand"; const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null; const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if ((uncharged || atk.needsNumber) && !biggest) continue; if ((uncharged || atk.needsNumber) && !biggest) continue;
const withNumber = (atk.perNumber || uncharged) && biggest; const mustNumber = uncharged || atk.needsNumber === true;
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0); const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
if (damage <= 0) continue; const numberChoices: (CardInstance | undefined)[] = atk.perNumber
// AMPLIFY doubles the heavy hitters (spells only, not thrown things). ? [undefined, ...numbers]
const amplified = amplify && damage >= 4 && !isWand && : [mustNumber ? biggest : undefined];
c.cardId !== "dagger" && c.cardId !== "large-rock"; for (const num of numberChoices) {
if (amplified) damage *= 2; if (mustNumber && !num) continue;
if (!best || damage > best.damage) { const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
best = { const base = atk.base + (atk.perNumber && num ? value : 0);
damage, if (base <= 0) continue;
cmd: { 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, type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId }, target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best. // An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}), ...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}), ...(num ? { numberInstanceIds: [num.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}), ...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
}, });
}; }
} }
} }
// STONE DEAD: number times the stones the victim carries — the displayed // 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) { if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length; const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown; 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 = { best = {
damage: dmg, damage: dmg, kill: dmg >= target.life, cost: biggestValue,
cmd: { cmd: {
type: "cast", instanceId: sd.instanceId, type: "cast", instanceId: sd.instanceId,
target: { kind: "player", playerId: targetId }, 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. /** 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 }; 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 if (!tier.buffs) return null; // the apprentice's book ends at the stones
// A curse on the clockwork gets scrubbed off. // A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some( 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. // Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
const myFloorTreasure = tier.guardGold const myFloorTreasure = tier.guardGold
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy) ? 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 && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) { if (!self.carriedTreasureId) {
const prize = view.treasures.find( const prize = view.treasures.find(
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here && (t) => t.position && !t.carriedBy && cellKey(t.position) === here &&
here !== cellKey(self.home), 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. // Wounded clockwork teleports clear of visible hunters.
@@ -1200,13 +1335,16 @@ export function automatonCommand(
const target = thief && visible.some((p) => p.id === thief.id) const target = thief && visible.some((p) => p.id === thief.id)
? thief ? thief
: visible.sort((a, b) => a.life - b.life)[0]!; : visible.sort((a, b) => a.life - b.life)[0]!;
// DROP OBJECT shakes my treasure out of the thief's hands. // DROP OBJECT shakes the treasure out of the hands that matter:
if (thief && target.id === thief.id) { // the thief of my gold, or any carrier whose delivery wins.
const dob = inHand(view, "drop-object"); {
if (dob) { 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 { return {
type: "cast", instanceId: dob.instanceId, 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) { if (exile) {
const carrier = visible.find((p) => { const carrier = visible.find((p) => {
if (thief && p.id === thief.id) return true; if (thief && p.id === thief.id) return true;
if (deliveryWins(view, p)) return true;
if (!p.carriedTreasureId) return false; if (!p.carriedTreasureId) return false;
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6; 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 // March. The thief-chase outranks everything; the berserker hunts wizards
// over gold; the worrier keeps its distance; the hunter goes to the gold. // over gold; the worrier keeps its distance; the hunter goes to the gold.
if (view.turn.movementUsed < view.turn.movementAllowance) { if (view.turn.movementUsed < view.turn.movementAllowance) {
@@ -1374,19 +1531,38 @@ export function automatonCommand(
const ownGoldCells = new Set(view.treasures const ownGoldCells = new Set(view.treasures
.filter((t) => t.owner === view.you && t.position && !t.carriedBy) .filter((t) => t.owner === view.you && t.position && !t.carriedBy)
.map((t) => cellKey(t.position!))); .map((t) => cellKey(t.position!)));
// A thief already underfoot is not a destination — with the chase // The chase exists to deliver a blow, so it claims the march only
// moot, march on the real goals (deliver, grab) instead of standing // while that blow is still live: an attack in hand, this turn's
// on them forever. // attack unspent, casting not yet closed. Those gates hold still as
const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null; // 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 const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells ? ownGoldCells
: thiefAfar : chasing
? new Set([cellKey(thiefAfar.position)]) ? new Set([cellKey(thief.position)])
: bankThreatened
? new Set([cellKey(self.home)])
: style === "berserker" && !self.carriedTreasureId : style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold) ? (enemyCells.size > 0 ? enemyCells : gold)
: gold; : gold;
const path = 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, objectives, { canUnlock }) ??
pathToward(view, self.position, enemyCells, { canUnlock }) ?? pathToward(view, self.position, enemyCells, { canUnlock }) ??
// No clean road to anything: grit the teeth and wade the hazards, // No clean road to anything: grit the teeth and wade the hazards,
@@ -1469,7 +1645,12 @@ export function automatonCommand(
// The war chest: when an attack in hand wants a number, the // The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer // biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn. // march, and walking is free next turn.
const wantsNumber = view.yourHand.some((c) => { // — 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]; const atk = ATTACKS[c.cardId];
if (!atk) return false; if (!atk) return false;
return atk.perNumber || atk.needsNumber === true || return atk.perNumber || atk.needsNumber === true ||
+281 -45
View File
@@ -195,6 +195,9 @@ export interface CastStack {
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */ /** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
cardId?: string }[]; cardId?: string }[];
waitingOn: PlayerId; 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. */ /** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean; defenderShielded?: boolean;
/** Set when a creature, not a wizard, delivers the attack. */ /** Set when a creature, not a wizard, delivers the attack. */
@@ -225,6 +228,9 @@ export interface CastParams {
hold?: boolean; hold?: boolean;
} }
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 5;
export interface GameConfig { export interface GameConfig {
playerIds: PlayerId[]; playerIds: PlayerId[];
seed: number; seed: number;
@@ -235,17 +241,18 @@ export interface GameConfig {
colors?: number[]; colors?: number[];
/** /**
* Rules revision, frozen per game so stored games replay unchanged. * Rules revision, frozen per game so stored games replay unchanged.
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not * Absent = rev 1, the baseline all earlier revisions collapsed into.
* applicable in a 2-player game."). Rev 3: WARD springs only when armed, * Rev 2: a wizard beside a door they can open (lock removed, door
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4: * unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
* creature blows open a counteraction window like any attack. Rev 5: * the doorway; the hallway behind them still cannot.
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points, * Rev 3: VISIONSTONE pierces its one wall for every sight the game
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a * asks of its bearer creations and utility spells included not
* teleport escape ("does not work against escape" the card face). * only direct attacks.
* Rev 7: no behavioral change every board opening carries warp sight * Rev 4: "the door will relock behind you" means it passing through
* in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the * an unlocked door shuts it at the walker's back unless a hand holds
* warp." the card face). Rev 8: nothing can be created on a warp token, * it; unpassed, it relocks at turn's end as before.
* and a SPEED bonus turn burns a turn of durations on the hastened wizard. * 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; deckRev?: number;
} }
@@ -348,19 +355,71 @@ export function losBlockers(state: GameState): Record<string, true> {
return blockers; return blockers;
} }
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still /** A door HELD open is propped ajar for every eye in the hallway. Any
* considered to block L.O.S." speaks of a CLOSED door, and * other door hangs shut, and a shut door blocks L.O.S. see doorsAjar
* the table holds doors open precisely to cast back through them. */ * for the one wizard who may pull it open and peek. */
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard { function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard {
if (state.heldDoors.length === 0) return board; if (state.heldDoors.length === 0) return board;
const edges = { ...board.edges }; const edges = { ...board.edges };
for (const h of state.heldDoors) delete edges[h.key]; for (const h of state.heldDoors) delete edges[h.key];
return { ...board, edges }; 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<string, EdgeState> | 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. */ /** LOS including square-filling blockers. */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean { function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state)); 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. */ /** Parse an edge key back into its north/west cell and side. */
@@ -409,7 +468,7 @@ function casterLos(
from: Cell, from: Cell,
to: Cell, to: Cell,
): boolean { ): 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); const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true; if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false; if (!displays(caster, "visionstone")) return false;
@@ -525,6 +584,8 @@ export type GameEvent =
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" } | { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean } | { 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; | { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell;
/** Origins in FINAL coordinates (the maze may renormalize after the /** Origins in FINAL coordinates (the maze may renormalize after the
* landing); from/to record the pre-shift request. */ * landing); from/to record the pre-shift request. */
@@ -641,6 +702,7 @@ export type Command =
| { type: "wardChoice"; play: boolean } | { type: "wardChoice"; play: boolean }
| { type: "warpStep" } | { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side } | { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureWarpStep"; creatureId: string }
| { type: "creatureAttack"; creatureId: string; targetId: string } | { type: "creatureAttack"; creatureId: string; targetId: string }
| { | {
type: "cast"; type: "cast";
@@ -693,7 +755,7 @@ type AttackEffect = {
sustains?: boolean; sustains?: boolean;
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */ /** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
keepInHand?: boolean; keepInHand?: boolean;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>) => string | null; validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
onResolved?: (ctx: ResolutionContext) => void; onResolved?: (ctx: ResolutionContext) => void;
}; };
@@ -1182,8 +1244,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
keepInHand: true, keepInHand: true,
// "Unlocks any door (door relocks behind you). Do not discard when used. // "Unlocks any door (door relocks behind you). Do not discard when used.
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK." // 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) => 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": { "remove-lock": {
kind: "neutral", kind: "neutral",
@@ -1306,7 +1374,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target.playerId === caster.id) return "you are already your own buddy"; 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); 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 || !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. // Effectively permanent: broken by the caster attacking the target.
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS); attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
return null; return null;
@@ -1474,14 +1542,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = cellKey(cmd.target.cell); const key = cellKey(cmd.target.cell);
const creature = creatureAt(state, cmd.target.cell); const creature = creatureAt(state, cmd.target.cell);
if (creature) { 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"); destroyCreature(state, events, creature, "dispel creation");
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind }); events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
return null; return null;
} }
const content = state.squareContents[key]; const content = state.squareContents[key];
if (!content) return "nothing created there"; 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]; delete state.squareContents[key];
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind }); events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
return null; return null;
@@ -1498,7 +1566,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); 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 || !target.alive) return "no such living player";
if (target.id === caster.id) return "you cannot drag yourself"; 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"; if (isLockedInPlace(state, target.id)) return "they are locked in place";
const from = target.position; const from = target.position;
dragToward(state, target, caster.position); dragToward(state, target, caster.position);
@@ -1507,7 +1575,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
} }
if (cmd.target?.kind === "cell") { if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.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 objects = state.groundObjects[key];
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key); const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
if (objects && objects.length > 0) { if (objects && objects.length > 0) {
@@ -1584,7 +1652,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
resolve: (state, events, caster) => { resolve: (state, events, caster) => {
for (const opp of state.players) { for (const opp of state.players) {
if (!opp.alive || opp.id === caster.id) continue; 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; if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
retreatFromSight(state, events, opp, caster.position); retreatFromSight(state, events, opp, caster.position);
} }
@@ -1680,7 +1748,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return "no such monster"; if (!creature) return "no such monster";
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no 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"; const boost = cmd.params?.boost === "movement" ? "movement" : "life";
if (boost === "movement") creature.movesPerTurn *= 2; if (boost === "movement") creature.movesPerTurn *= 2;
else creature.maxDamage *= 2; else creature.maxDamage *= 2;
@@ -1882,7 +1950,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 || (state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key); state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to glue down"; 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; state.gluedCells[key] = true;
// "a duration equal to twice the NUMBER card played" // "a duration equal to twice the NUMBER card played"
const turns = magnitude.duration * 2; const turns = magnitude.duration * 2;
@@ -1906,7 +1974,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 || (state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key); state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to lock up"; 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 }; state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell }); events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
return null; return null;
@@ -1924,7 +1992,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ka === kb) return "pick two different squares"; if (ka === kb) return "pick two different squares";
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast"; 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 (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 itemsA = state.groundObjects[ka] ?? [];
const itemsB = state.groundObjects[kb] ?? []; const itemsB = state.groundObjects[kb] ?? [];
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka); const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
@@ -1987,7 +2055,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target?.kind === "cell") { if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell); const key = cellKey(cmd.target.cell);
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block"; 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]; delete state.squareContents[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell }); 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." // "Solid stone block turns into a WATERWALL with a range and damage of 4."
@@ -2103,11 +2171,19 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
"mental-force": { "mental-force": {
kind: "attack", kind: "attack",
baseDamage: () => 0, // no LOS printed baseDamage: () => 0, // no LOS printed
validate: (state, cmd) => { validate: (state, cmd, caster, target) => {
const cell = cmd.params?.cell; const cell = cmd.params?.cell;
if (!cell) return "say where they go (within three moved spaces)"; if (!cell) return "say where they go (within three moved spaces)";
if (!boardView(state).cells[cellKey(cell)]) return "off the board"; if (!boardView(state).cells[cellKey(cell)]) return "off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone"; 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; return null;
}, },
onResolved: (ctx) => { onResolved: (ctx) => {
@@ -2115,7 +2191,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (isLockedInPlace(ctx.state, ctx.defender.id)) return; if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell; const to = ctx.stack.params?.cell;
if (!to) return; // an ambush armed without a destination fizzles 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; const from = ctx.defender.position;
ctx.defender.position = to; ctx.defender.position = to;
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" }); 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<string, AttackEffect | NeutralEffect | CounterEffect>
const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); 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 || !other.alive) return "no such living player";
if (other.id === caster.id) return "that is already your home"; 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) => const onHome = (home: Cell) =>
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length; state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
if (onHome(caster.home) !== onHome(other.home)) { 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)) { if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it"; 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; return cell;
} }
@@ -2860,6 +2941,57 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
checkVictory(state, events); 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 { function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
const blocked = requireActionsAvailable(prev); const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked); 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). */ /** 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; if (cellKey(from) === cellKey(to)) return 0;
const seen = new Map<string, number>([[cellKey(from), 0]]); const seen = new Map<string, number>([[cellKey(from), 0]]);
const queue: Cell[] = [from]; const queue: Cell[] = [from];
@@ -3370,8 +3502,17 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
const d = seen.get(cellKey(cur))!; const d = seen.get(cellKey(cur))!;
if (d >= 8) break; // teleport range is 4; stop early if (d >= 8) break; // teleport range is 4; stop early
for (const side of SIDES) { for (const side of SIDES) {
const n = neighbor(cur, side); let n = neighbor(cur, side);
if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue; 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); seen.set(cellKey(n), d + 1);
if (cellKey(n) === cellKey(to)) return d + 1; if (cellKey(n) === cellKey(to)) return d + 1;
queue.push(n); queue.push(n);
@@ -3384,6 +3525,7 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
// Setup // Setup
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } { export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV };
const n = config.playerIds.length; const n = config.playerIds.length;
let rng = createRng(config.seed); let rng = createRng(config.seed);
const events: GameEvent[] = []; 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 "wardChoice": return err("no grab is hanging on your Ward");
case "warpStep": return doWarpStep(state); case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); 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 "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
case "cast": return doCast(state, command); case "cast": return doCast(state, command);
case "setAmbush": return doSetAmbush(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; if (sustainedOn(state, other.id, "fear").length === 0) continue;
const d = dreadDistance(board, other.position, to); const d = dreadDistance(board, other.position, to);
const dBefore = dreadDistance(board, other.position, from); 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; 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<string>([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. */ /** 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 { function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
state.turn.movementUsed++; state.turn.movementUsed++;
@@ -3831,9 +4023,27 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
p.position = { ...warp.to.cell }; p.position = { ...warp.to.cell };
via = "warp"; via = "warp";
crossedFirewall = true; 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; p.position = dest;
via = "step"; 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") { } else if (edge === "firewall") {
// "Passing through it does four points of magical damage." Firewalls // "Passing through it does four points of magical damage." Firewalls
// burn even a MIST-BODY. // 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.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"); if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
const events: GameEvent[] = []; 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) && if (!isAdjacentToEdge(p.position, cell, side) &&
!losToEdge(board, p.position, cell, side)) { !losToEdge(board, p.position, cell, side)) {
return err("you cannot see that wall from here"); return err("you cannot see that wall from here");
@@ -4627,6 +4837,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) { if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`); 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 // 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. // charge per use, one use per turn; discarded when the last charge goes.
@@ -4823,6 +5036,28 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
} }
return { ok: true, state, events: events2 }; 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 || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); 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); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
@@ -4844,7 +5079,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id), (s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
); );
if (effect.validate) { if (effect.validate) {
const problem = effect.validate(state, cmd); const problem = effect.validate(state, cmd, caster, target);
if (problem) return err(problem); if (problem) return err(problem);
} }
if (inHand.cardId === "waterbolt") { if (inHand.cardId === "waterbolt") {
@@ -4932,6 +5167,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
kind: effect.physical ? "physical" : "spell", kind: effect.physical ? "physical" : "spell",
counters: [], counters: [],
waitingOn: target.id, waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
}; };
state.lastSpellUsed[caster.id] = inHand.cardId; state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents]; const events: GameEvent[] = [...wandEvents, ...preEvents];
@@ -5089,8 +5325,8 @@ function checkAmbushes(
sprung = context.pickedUpTreasure === true; sprung = context.pickedUpTreasure === true;
} else if (context.movedFrom) { } else if (context.movedFrom) {
if (ambush.trigger.kind === "los") { if (ambush.trigger.kind === "los") {
const before = gameLos(state, owner.position, context.movedFrom); const before = ambushLos(state, owner, owner.position, context.movedFrom);
const now = gameLos(state, owner.position, actor.position); const now = ambushLos(state, owner, owner.position, actor.position);
sprung = now && !before; sprung = now && !before;
} else if (ambush.trigger.kind === "near") { } else if (ambush.trigger.kind === "near") {
const dist = (c: Cell) => const dist = (c: Cell) =>
@@ -5102,7 +5338,7 @@ function checkAmbushes(
// The committed spell must be legal right now, or the ambush stays armed. // The committed spell must be legal right now, or the ambush stays armed.
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect; 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.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers); state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
+59 -6
View File
@@ -2,7 +2,7 @@
// The server sends this after every state change; clients never see the // The server sends this after every state change; clients never see the
// deck order or other players' hands. // 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 { cardDef, type CardInstance } from "./cards";
import { import {
boardView, boardView,
@@ -62,6 +62,8 @@ export interface GameView {
/** Safes standing open (their combination entered this turn). */ /** Safes standing open (their combination entered this turn). */
openSafes: string[]; openSafes: string[];
groundObjects: Record<string, CardInstance[]>; groundObjects: Record<string, CardInstance[]>;
/** The rules revision this game was dealt under. */
deckRev: number;
doorStates: Record<string, "jammed" | "removed">; doorStates: Record<string, "jammed" | "removed">;
/** Accumulated attack damage per edge (public — cracks show). */ /** Accumulated attack damage per edge (public — cracks show). */
wallDamage: Record<string, number>; wallDamage: Record<string, number>;
@@ -153,6 +155,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
groundObjects: Object.fromEntries( groundObjects: Object.fromEntries(
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]), Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
), ),
deckRev: state.config.deckRev ?? 1,
doorStates: { ...state.doorStates }, doorStates: { ...state.doorStates },
wallDamage: { ...state.wallDamage }, wallDamage: { ...state.wallDamage },
openDoorEdges: [...state.openDoorEdges], openDoorEdges: [...state.openDoorEdges],
@@ -197,16 +200,50 @@ export function sightedCellsFor(view: GameView): Set<string> {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); 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; return out;
} }
/** The board-as-seen and sight blockers this view's sight rules run against. */ /** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } { function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// 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; 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 }; const edges = { ...board.edges };
for (const k of view.heldDoorEdges) delete edges[k]; for (const k of openKeys) delete edges[k];
board = { ...board, edges }; board = { ...board, edges };
} }
const blockers: Record<string, true> = {}; const blockers: Record<string, true> = {};
@@ -259,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
*/ */
export function stackSightTrace( export function stackSightTrace(
view: GameView, 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; const stack = view.stack;
if (!stack || stack.creatureId) return null; if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) 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 || !d) return null;
if (a.position.x === d.position.x && a.position.y === d.position.y) 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); 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([ const CREATION_CARD_IDS = new Set([
+219
View File
@@ -14,6 +14,7 @@ import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */ /** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId { function actingSeat(state: GameState): PlayerId {
return ( return (
state.wardPending?.ownerId ??
state.stack?.waitingOn ?? state.stack?.waitingOn ??
state.pendingDiscard ?? state.pendingDiscard ??
state.chaosPending?.queue[0] ?? 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;
}
});
});
+162 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game"; 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 { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards"; 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"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */ /** 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); 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", () => { it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = threeGame(); let { state } = threeGame();
state = toRound2(state); 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", () => { describe("teleport as a counteraction", () => {
it("the attack has no chance of hitting you — you are simply elsewhere", () => { it("the attack has no chance of hitting you — you are simply elsewhere", () => {
let { state } = newGame(); 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); 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();
}
});
});
+66
View File
@@ -720,4 +720,70 @@ describe("fear holds off monsters and unwilling feet alike", () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("dread"); 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");
});
}); });
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game"; 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 type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -111,7 +111,7 @@ describe("doors", () => {
throw new Error("no door on this board"); 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(); let { state } = newGame();
const me = activePlayer(state); const me = activePlayer(state);
const door = findDoor(state); const door = findDoor(state);
@@ -130,9 +130,8 @@ describe("doors", () => {
state = must(state, me.id, { type: "move", direction: dir }); state = must(state, me.id, { type: "move", direction: dir });
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other)); expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
// Relocks when the turn ends. // "It will relock behind you": shut at the walker's back at once —
state = must(state, me.id, { type: "endTurn", draw: 0 }); // the way back is barred without another pick.
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(state.openDoorEdges.length).toBe(0); expect(state.openDoorEdges.length).toBe(0);
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false); 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); 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(); let { state, cell, side, holder, pursuer } = sightRig();
const pick = giveCard(state, holder, "pick-lock"); const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, { state = must(state, holder, {
@@ -453,9 +452,116 @@ describe("a held door is an open doorway to the eye", () => {
target: { kind: "edge", cell, side }, target: { kind: "edge", cell, side },
}); });
const fb = giveCard(state, holder, "fireball", "FB", 1); 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 }, 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); 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);
}); });
}); });
+52
View File
@@ -57,6 +57,8 @@ import {
summarize, summarize,
viewForPlayer, viewForPlayer,
type Room, type Room,
kickSeat,
abandonRoom,
} from "./rooms"; } from "./rooms";
import { engagementStats, recordHotseat } from "./stats"; import { engagementStats, recordHotseat } from "./stats";
import { getShare, loadShares, mintShare } from "./shares"; 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<string>(); const pumping = new Set<string>();
function runBots(room: Room): void { function runBots(room: Room): void {
if (pumping.has(room.id)) return; 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 })); broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
// The game's end unmasks the mystery machines in the roster. // The game's end unmasks the mystery machines in the roster.
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room)); 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 }[]); botRemark(room, step.seat, step.events as { type: string }[]);
setTimeout(tick, BOT_STEP_MS); setTimeout(tick, BOT_STEP_MS);
}; };
@@ -526,6 +546,38 @@ wss.on("connection", (socket) => {
session.token = null; session.token = null;
break; 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": { case "addBot": {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
+51 -6
View File
@@ -20,8 +20,9 @@ import {
type GameState, type GameState,
type GameView, type GameView,
type PlayerId, type PlayerId,
CURRENT_RULES_REV,
} from "@wizwar/engine"; } 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"; import { recordRoom } from "./stats";
export interface LoggedCommand { export interface LoggedCommand {
@@ -56,7 +57,7 @@ const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). /** 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; * 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. */ * 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"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -152,6 +153,33 @@ export function joinRoom(
return { token: fresh }; 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. */ /** Everyone gets their chosen standee; the undecided get the first free one. */
export function resolveColors(room: Room): number[] { export function resolveColors(room: Room): number[] {
const taken = new Set<number>(); const taken = new Set<number>();
@@ -290,17 +318,28 @@ function actingSeat(room: Room): PlayerId | null {
* One automaton command, if the maze is waiting on clockwork. Null when a * 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). * 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); const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null; if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat); const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat); const bot = room.bots.get(seat);
const chosen = automatonCommand(view, bot?.style, bot?.tier); 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)); let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) { if ("error" in r) {
// A refused choice retries with the fallback — unless the fallback IS // A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass. // 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: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" }); if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) { if ("error" in r) {
@@ -308,7 +347,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
return null; return null;
} }
} }
return { seat, events: r.events }; return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
} }
export interface GameSummary { export interface GameSummary {
@@ -600,8 +639,14 @@ export function loadPersistedRooms(): void {
chat: [], chat: [],
bots: new Map(), bots: new Map(),
}; };
if (lines.some((l) => l.kind === "abandon")) continue;
for (const line of lines.slice(1)) { 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) { if (line.bot) {
room.players.push(line.name); room.players.push(line.name);
room.bots.set(line.name, { room.bots.set(line.name, {
+24 -2
View File
@@ -3,7 +3,7 @@
// command. Because the engine is deterministic, replaying a file rebuilds // command. Because the engine is deterministic, replaying a file rebuilds
// the exact game state — server restarts lose nothing. // 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"; import { join } from "node:path";
export interface RoomMetaLine { export interface RoomMetaLine {
@@ -58,7 +58,19 @@ export interface ChatLine {
at: string; 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"); const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
@@ -103,3 +115,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
} }
return rooms; 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`));
}
+29 -1
View File
@@ -342,7 +342,7 @@
"wall-of-fire": "click a corridor line for the fire", "wall-of-fire": "click a corridor line for the fire",
"waterwall": "click a corridor line — the wave collapses at once", "waterwall": "click a corridor line — the wave collapses at once",
"pick-lock": "click a locked door", "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", "jam-lock": "click a door to jam its lock solid",
"remove-lock": "click a door to strip its lock for good", "remove-lock": "click a door to strip its lock for good",
"create-door": "click a wall for the new door", "create-door": "click a wall for the new door",
@@ -720,6 +720,15 @@
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side }); dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
return; 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; selectedCreature = null;
return; return;
@@ -1256,6 +1265,7 @@
<header class="masthead"> <header class="masthead">
<span class="mast-title">Wiz-War</span> <span class="mast-title">Wiz-War</span>
<span class="mast-sub">sixth edition</span> <span class="mast-sub">sixth edition</span>
<span class="mast-version" title="deployed">{__BUILD_STAMP__}</span>
<button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}> preferences</button> <button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}> preferences</button>
{#if local.active} {#if local.active}
<span class="mast-room">hotseat</span> <span class="mast-room">hotseat</span>
@@ -1811,6 +1821,10 @@
<span class="dot" style:background="#b3a687"></span> <span class="dot" style:background="#b3a687"></span>
{/if} {/if}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""} {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
{#if net.you === net.hostId && p !== net.hostId}
<button class="stamp tiny kick" title="clear this seat"
onclick={() => net.kickSeat(p)}>✕</button>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -1834,6 +1848,8 @@
{/each} {/each}
</div> </div>
{#if net.you === net.hostId} {#if net.you === net.hostId}
<button class="stamp tiny abandon" title="dissolve this room for everyone"
onclick={() => net.abandonRoom()}>abandon room</button>
{#if net.players.length < 6} {#if net.players.length < 6}
<span class="bot-row"> <span class="bot-row">
<span class="bot-label">⚙ seat a</span> <span class="bot-label">⚙ seat a</span>
@@ -2219,6 +2235,10 @@
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label> <label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
{/if} {/if}
{#if selectedCard?.cardId === "master-key" && isYourTurn &&
!me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
<button class="stamp tiny" onclick={castSelfWithNumber}>Display the key</button>
{/if}
{#if selectedCard?.cardId === "rotate-sector"} {#if selectedCard?.cardId === "rotate-sector"}
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label> <label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
<span>— click the sector</span> <span>— click the sector</span>
@@ -2463,6 +2483,14 @@
text-transform: uppercase; text-transform: uppercase;
color: #8d8672; 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 { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; } .mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; } .mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
+27 -3
View File
@@ -68,7 +68,7 @@
effects?: BoardFx[] | null; effects?: BoardFx[] | null;
/** The sight line an attack in progress traveled — proof against "how /** The sight line an attack in progress traveled — proof against "how
* can he even see me?", drawn leg by leg through any warp mouth. */ * 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(); } = $props();
@@ -390,14 +390,20 @@
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3} x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
width={CELL * 0.4} height={CELL * 0.4} width={CELL * 0.4} height={CELL * 0.4}
preserveAspectRatio="xMidYMid slice" 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={() => {}}
> >
<title>{o.cardId}</title> <title>{o.cardId}</title>
</image> </image>
{:else} {:else}
<rect <rect
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16} x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
width={12} height={10} rx="2" class="ground-object" width={12} height={10} rx="2" class="ground-object peekable"
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
onkeydown={() => {}}
> >
<title>{o.cardId}</title> <title>{o.cardId}</title>
</rect> </rect>
@@ -661,8 +667,15 @@
<!-- the sight line an attack traveled, leg by leg through any warp mouth --> <!-- the sight line an attack traveled, leg by leg through any warp mouth -->
{#if sightTrace} {#if sightTrace}
{#if sightTrace.bend}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.bend.mid} trace={sightTrace.trace} />
<SightTraceOverlay from={sightTrace.bend.mid} to={sightTrace.to} trace={sightTrace.bend.trace} />
<rect x={sightTrace.bend.mid.x * CELL + CELL / 2 - 6} y={sightTrace.bend.mid.y * CELL + CELL * 0.36 - 6}
width={12} height={12} class="sight-corner" transform="rotate(45 {sightTrace.bend.mid.x * CELL + CELL / 2} {sightTrace.bend.mid.y * CELL + CELL * 0.36})" />
{:else}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} /> <SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
{/if} {/if}
{/if}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) --> <!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true"> <g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)} {#each effects ?? [] as fx (fx.id)}
@@ -752,6 +765,17 @@
fill: #6a5c44; fill: #6a5c44;
pointer-events: none; pointer-events: none;
} }
.peekable { cursor: pointer; pointer-events: all; }
.sight-corner {
fill: none;
stroke: #c9a72a;
stroke-width: 2;
animation: sight-pulse 1.6s ease-in-out infinite;
}
@keyframes sight-pulse {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.35; }
}
.dim-cell { .dim-cell {
fill: rgba(12, 9, 5, 0.55); fill: rgba(12, 9, 5, 0.55);
pointer-events: none; pointer-events: none;
+43 -19
View File
@@ -103,6 +103,16 @@ export function humanize(e: GameEvent): string | null {
case "doorHeld": return `${e.player} holds the door open.`; case "doorHeld": return `${e.player} holds the door open.`;
case "doorReleased": return `The held door swings shut.`; case "doorReleased": return `The held door swings shut.`;
case "doorsRelocked": return `The door swings shut and relocks.`; case "doorsRelocked": return `The door swings shut and relocks.`;
case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
case "cardsStolenPrivate": return e.cards.length > 0
? `Stolen into your hand: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
: null;
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
case "doorJammed": return `${e.player} jams a door's lock solid.`; case "doorJammed": return `${e.player} jams a door's lock solid.`;
case "lockRemoved": return `${e.player} removes a door's lock for good.`; case "lockRemoved": return `${e.player} removes a door's lock for good.`;
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`; case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
@@ -215,18 +225,6 @@ export interface LogLine {
* momentSteps, so a chronicle turn number addresses the same commands. */ * momentSteps, so a chronicle turn number addresses the same commands. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]); const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
/** Does this event make a turn worth reliving? Combat and spectacle. */
export function isNotableEvent(e: GameEvent): boolean {
if (e.type === "spellCast") return e.target !== null || e.targetCell !== null;
return NOTABLE_EVENTS.has(e.type);
}
const NOTABLE_EVENTS = new Set([
"punched", "attackResolved", "damaged", "died", "knockedBack", "shoved",
"washedBack", "retreatedInHorror", "creatureAttacked", "boobytrapSprung",
"firewallBurned", "objectThrown", "gameWon", "positionsSwapped",
"teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater",
"waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped",
]);
const SEAT_KEY = "wizwar-seat"; const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats"; const SEATS_KEY = "wizwar-seats";
@@ -321,7 +319,6 @@ class Net {
* counts, so a chronicle line can name the turn it belongs to. */ * counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1; private turnCounter = -1;
/** The turn that already carries an instant-replay eye (one per turn). */ /** The turn that already carries an instant-replay eye (one per turn). */
private eyeTurn = -1;
/** The turn whose moment reel is open (share links point at it). */ /** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null; private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null; private shareResolve: ((url: string) => void) | null = null;
@@ -444,12 +441,11 @@ class Net {
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++; if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
const line = humanize(e); const line = humanize(e);
if (line) { if (line) {
// One eye per turn — except the crown: the game-winning // Every turn wears its eye, on the header line that opens
// line always carries its own, whatever came before it. // it — the players judge what is share-worthy. The game-
// winning line carries its own besides.
const notable = this.turnCounter >= 0 && const notable = this.turnCounter >= 0 &&
(e.type === "gameWon" || (e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
(this.turnCounter !== this.eyeTurn && isNotableEvent(e)));
if (notable) this.eyeTurn = this.turnCounter;
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }]; this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
} }
} }
@@ -459,6 +455,14 @@ class Net {
} }
break; break;
} }
case "kicked":
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId}`, turn: null, notable: false }];
this.leaveLocal();
break;
case "roomAbandoned":
this.log = [...this.log, { text: `— the host closed room ${msg.roomId}`, turn: null, notable: false }];
this.leaveLocal();
break;
case "transferCode": case "transferCode":
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt }; this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
break; break;
@@ -577,6 +581,14 @@ class Net {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) }); this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
} }
kickSeat(name: string): void {
this.send({ type: "kickSeat", name });
}
abandonRoom(): void {
this.send({ type: "abandonRoom" });
}
rollTableDie(): void { rollTableDie(): void {
this.send({ type: "rollDie" }); this.send({ type: "rollDie" });
} }
@@ -648,6 +660,19 @@ class Net {
} }
/** Forget the remembered seat and return to the lobby. */ /** Forget the remembered seat and return to the lobby. */
/** Client-side teardown when the SERVER detached us (kick, abandon). */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY);
this.roomId = null;
this.roomIdPending = null;
this.view = null;
this.started = false;
this.players = [];
this.token = null;
this.spectating = false;
this.audience = 0;
}
leave(): void { leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat) this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
localStorage.removeItem(SEAT_KEY); localStorage.removeItem(SEAT_KEY);
@@ -698,7 +723,6 @@ class Net {
private resetChronicle(): void { private resetChronicle(): void {
this.log = []; this.log = [];
this.turnCounter = -1; this.turnCounter = -1;
this.eyeTurn = -1;
} }
/** Summon one turn's reel by its chronicle turn number. */ /** Summon one turn's reel by its chronicle turn number. */
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="svelte" />
/** Build moment, yyyy.mm.dd.hh.mm — injected by vite.config.ts. */
declare const __BUILD_STAMP__: string;
+14
View File
@@ -1,6 +1,20 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte"; import { svelte } from "@sveltejs/vite-plugin-svelte";
// Deploys build locally moments before rsync, so build time IS deploy time.
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const stamp = [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
pad(now.getHours()),
pad(now.getMinutes()),
].join(".");
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
define: {
__BUILD_STAMP__: JSON.stringify(stamp),
},
}); });
+175
View File
@@ -0,0 +1,175 @@
// Claude's seat at the table: a one-shot websocket client. Each run
// connects, resumes the seat by token, performs one verb, prints, exits.
// node claude-seat.mjs join <ROOM> <NAME>
// node claude-seat.mjs view
// node claude-seat.mjs do '<command json>'
// node claude-seat.mjs chat "text"
import WebSocket from "ws";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
// Seat state (room, name, token) persists between one-shot invocations.
const SEAT_FILE = process.env.WIZWAR_SEAT ?? "/tmp/wizwar-claude-seat.json";
const URL_WS = process.env.WIZWAR_SERVER ?? "wss://wizwar.kestrelsnest.social/ws";
const [verb, ...args] = process.argv.slice(2);
const seat = existsSync(SEAT_FILE) ? JSON.parse(readFileSync(SEAT_FILE, "utf8")) : null;
const ws = new WebSocket(URL_WS);
const send = (m) => ws.send(JSON.stringify(m));
const die = (msg, code = 1) => { console.log(msg); process.exit(code); };
setTimeout(() => die("timeout: no answer from the maze"), 20000);
let view = null;
let pendingEvents = [];
let historyEvents = [];
function cellK(c) { return `${c.x},${c.y}`; }
function renderView(v) {
const B = v.board;
const lines = [];
const label = new Map(); // cellKey -> 2-char code
const codes = {};
v.players.forEach((p, i) => {
codes[p.id] = String(i + 1);
label.set(cellK(p.position), (p.id === v.you ? "C" : "P") + (i + 1));
});
const treas = new Map();
for (const t of v.treasures) if (t.position && !t.carriedBy) treas.set(cellK(t.position), t);
// top border row by row
for (let y = 0; y < B.height; y++) {
let top = "";
let mid = "";
for (let x = 0; x < B.width; x++) {
const k = `${x},${y}`;
if (!B.cells[k]) { top += " "; mid += " "; continue; }
const nEdge = y === 0 ? (B.edges[`H:${x},${y - 1}`] ?? "open") : (B.edges[`H:${x},${y - 1}`] ?? "open");
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");
const wc = wEdge === "wall" ? "|" : wEdge === "door" ? "D" : wEdge === "firewall" ? "F" : " ";
let body = label.get(k) ?? "";
if (!body) {
const sq = v.squareContents[k];
if (sq) body = sq.kind.slice(0, 2).toUpperCase();
else if (treas.has(k)) body = "$" + (treas.get(k).owner === v.you ? "c" : codes[treas.get(k).owner] ?? "?");
else if (B.homes.some((h) => cellK(h) === k)) {
const who = v.players.find((p) => cellK(p.home) === k);
body = "h" + (who ? (who.id === v.you ? "C" : codes[who.id]) : "?");
} else if ((v.groundObjects[k] ?? []).length) body = "ob";
else if (v.creatures.some((c) => cellK(c.position) === k)) {
body = v.creatures.find((c) => cellK(c.position) === k).kind.slice(0, 2);
} else body = " ";
}
mid += wc + " " + body.padEnd(2, " ") + " ";
}
lines.push(top + "+");
lines.push(mid + "|");
}
let bottom = "";
for (let x = 0; x < B.width; x++) bottom += "+————";
lines.push(bottom + "+");
const out = [];
out.push(`ROOM ${seat?.roomId} — you are ${v.you} (round ${v.turn.round}, ${v.activePlayerId}'s turn)`);
out.push(lines.join("\n"));
out.push("WARPS (rim passages): " + B.warps.map((w) => `${cellK(w.from.cell)}${w.from.side}${cellK(w.to.cell)}`).join(" "));
if (v.dimWarps.length) {
out.push("!! DIMENSIONAL WARP TOKENS (player wormholes — CHECK EVERY TURN): " +
v.dimWarps.map((w) => `${cellK(w.a)}${cellK(w.b)}`).join(" "));
}
for (const p of v.players) {
const t = p.carriedTreasureId ? " CARRYING TREASURE" : "";
out.push(` ${p.id === v.you ? "ME " : " "}${p.id}: ${p.life} life @${cellK(p.position)} home@${cellK(p.home)} hand:${p.handCount}${t}${p.alive ? "" : " DEAD"} displayed:[${p.displayed.map((c) => c.cardId).join(",")}]`);
}
out.push("TREASURES: " + v.treasures.map((t) => `${t.id}(${t.owner})${t.carriedBy ? `held by ${t.carriedBy}` : t.position ? `@${cellK(t.position)}` : "?"}`).join(" "));
if (v.sustained.length) out.push("SUSTAINED: " + v.sustained.map((s) => `${s.cardId} on ${s.targetId} (${s.remainingTurns})`).join(" "));
const sq = Object.entries(v.squareContents);
if (sq.length) out.push("CONTENTS: " + sq.map(([k, c]) => `${c.kind}@${k}`).join(" "));
if (v.creatures.length) out.push("CREATURES: " + v.creatures.map((c) => `${c.kind}@${cellK(c.position)} (${c.controllerId}, ${c.life} life)`).join(" "));
out.push(`TURN: moves ${v.turn.movementUsed}/${v.turn.movementAllowance} attackUsed:${v.turn.attackUsed} actionsEnded:${v.turn.actionsEnded} numberPlayed:${v.turn.numberPlayedForMovement}`);
out.push("MY HAND: " + v.yourHand.map((c) => `${c.instanceId}`).join(" "));
if (v.stack) out.push("STACK! " + JSON.stringify(v.stack));
if (v.wardPending) out.push("WARD PENDING: " + JSON.stringify(v.wardPending));
if (v.chaosPending) out.push("CHAOS PENDING: " + JSON.stringify(v.chaosPending));
if (v.outOfTurnWindow) out.push("OUT-OF-TURN WINDOW: " + JSON.stringify(v.outOfTurnWindow));
if (v.phase === "finished") out.push(`GAME OVER — winner: ${v.winner} (${v.winReason})`);
return out.join("\n");
}
function eventLine(e) {
const skip = new Set(["cardsDealtPrivate"]);
if (skip.has(e.type)) return null;
return JSON.stringify(e);
}
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "error") die(`SERVER: ${msg.message}`);
if (msg.type === "seat") {
// A join names its room explicitly; only a resume inherits the file's.
const s = { roomId: verb === "join" ? args[0].toUpperCase() : seat.roomId, name: msg.playerId, token: msg.token };
writeFileSync(SEAT_FILE, JSON.stringify(s));
if (verb === "join") { console.log(`seated as ${msg.playerId} in ${s.roomId}`); afterJoin(); }
}
if (msg.type === "events") {
// A join replays the whole chronicle (replayed: true); only live
// events are news. `view` prints the tail of history instead.
if (!msg.replayed) {
for (const e of msg.events) { const l = eventLine(e); if (l) pendingEvents.push(l); }
} else if (verb === "view") {
for (const e of msg.events) { const l = eventLine(e); if (l) historyEvents.push(l); }
}
}
if (msg.type === "state") { view = msg.view; finishAfterState(); }
if (msg.type === "catchUp") {
const steps = msg.steps;
if (steps.length) view = steps[steps.length - 1].view;
finish();
}
if (msg.type === "chat") pendingEvents.push(`CHAT ${msg.player}: ${msg.text}`);
if (msg.type === "room") { /* roster updates, ignore */ }
});
let done = false;
function finish() {
if (done) return; done = true;
if (verb === "view" && historyEvents.length) {
console.log("RECENT EVENTS (history tail):\n" + historyEvents.slice(-12).join("\n") + "\n");
}
if (pendingEvents.length) console.log("NEW EVENTS:\n" + pendingEvents.join("\n") + "\n");
if (view) console.log(renderView(view));
else console.log("(no game running yet — waiting for the start)");
process.exit(0);
}
let stateTimer = null;
function finishAfterState() {
// commands produce one state per player broadcast; give trailing events a beat
if (stateTimer) clearTimeout(stateTimer);
stateTimer = setTimeout(finish, 700);
}
function afterJoin() {
send({ type: "catchUp", sinceSeq: 0 });
setTimeout(() => { if (!done) finish(); }, 6000);
}
ws.on("open", () => {
if (verb === "join") {
if (!args[0] || !args[1]) die("usage: join <ROOM> <NAME>");
send({ type: "join", roomId: args[0].toUpperCase(), name: args[1], token: null });
} else if (!seat) {
die("no seat yet — join first");
} else {
send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
setTimeout(() => {
if (verb === "view") {
send({ type: "catchUp", sinceSeq: 0 });
} else if (verb === "do") {
send({ type: "command", command: JSON.parse(args[0]) });
setTimeout(() => { if (!done) finish(); }, 8000);
} else if (verb === "chat") {
send({ type: "chat", text: args[0] });
setTimeout(() => { console.log("said."); process.exit(0); }, 800);
} else die(`unknown verb: ${verb}`);
}, 600);
}
});
ws.on("error", (e) => die(`socket error: ${e.message}`));