Merge branch 'main' into first-person-play
This commit is contained in:
@@ -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".
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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`));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Vendored
+5
@@ -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;
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`));
|
||||||
Reference in New Issue
Block a user