Compare commits
47
Commits
@@ -113,6 +113,173 @@ probing legality is free, so when unsure, try it and read the error.
|
||||
- Grab-turn-one openings feel great and prove nothing: deliveries are
|
||||
the only score. He banked quietly both times while I showboated.
|
||||
|
||||
## Game-five scars (eliminated by a rule I implemented myself)
|
||||
|
||||
- **THE ELIMINATION CLOCK**: the moment BOTH treasures you protect sit
|
||||
on living opponents' home bases, you are OUT — points, life, and
|
||||
position notwithstanding. In multiplayer this clock starts ticking
|
||||
the instant your second chest is picked up by anyone. From then on,
|
||||
every turn must either recover a chest or end the game first. I spent
|
||||
three turns optimizing deliveries while two bots quietly carried my
|
||||
death certificate.
|
||||
- **FEAR IS A SELF-CAST AURA** — read the card, not your assumptions:
|
||||
"no player or monster will move to within 3 spaces of you," walls
|
||||
irrelevant, and anyone already within 3 MUST flee on their turn. It
|
||||
is armor for carriers and a hearth-denial tool: a feared wizard
|
||||
standing 2 from your home makes delivery impossible. I invented an
|
||||
elaborate theory about enemy goalkeeping instead of reading my own
|
||||
engine's card text. Check `data/cards.json` when a rule surprises —
|
||||
it is one grep away and it is verbatim.
|
||||
- In four-player, Kestrel plays the TABLE, not the duel: his SWAP
|
||||
yanked a bot off its own doorstep mid-delivery and parked himself by
|
||||
unclaimed treasures — one card, two tempo hits. Expect every card he
|
||||
plays to hurt two players at once, and ask which two before deciding
|
||||
it wasn't aimed at you.
|
||||
- CREATE WALL can sever your announced return route the turn after you
|
||||
telegraph it. If your road home is unique, either don't stand at the
|
||||
far end of it, or carry the picks/doors to re-open it. And CREATE
|
||||
DOOR makes LOCKED doors — it is a key-cutter, worthless without picks.
|
||||
- Bot threat model: archmage automatons DO deliver, but their brains
|
||||
can dither inexplicably (one toured the map for two rounds carrying
|
||||
my elimination). Never count on bot incompetence — count turns to
|
||||
their delivery square as if they play perfectly, and treat every
|
||||
reprieve as a gift, not a pattern.
|
||||
|
||||
## Game-six scars (the heist)
|
||||
|
||||
- **THORNBUSH IS A JAIL, NOT A FORT**: entering one ends your turn,
|
||||
costs your ENTIRE next turn, and deals 1 damage. The "cannot be
|
||||
attacked" line is the bait; the two-turn tempo loss is the hook.
|
||||
Eric: "Few will dive into one unless they are desperate. You were
|
||||
confident, but not desperate." Enter a bush only when the
|
||||
alternative is death — and READ THE WHOLE CARD before calling
|
||||
anything a shelter. Three rules self-owns in two games (fear,
|
||||
thornbush) — the full text is one grep away, in my own repo.
|
||||
- **MIST-BODY IS A BANK ROBBER**: 5 turns untouchable, walks through
|
||||
doors and walls, picks up treasures, immune to every attack-typed
|
||||
card (drop-object, card-erasure, teleport-opponent included). While
|
||||
an enemy is misted, do not plan counterplay — plan for WHERE the
|
||||
mist will be when it lapses, and meanwhile DISPERSE value: banking
|
||||
everything on one home square builds the heist target. A creature
|
||||
guard is scenery to a ghost.
|
||||
- The bait-fork fallacy: I "forked" him between my two treasures, but
|
||||
both forks were MY assets. He ignored both and took the third thing
|
||||
— the treasure I'd just consolidated. When your fork only threatens
|
||||
what you own, it isn't a fork; it's a to-do list you published.
|
||||
|
||||
## Game-nine scar: concentrate your thefts
|
||||
|
||||
- Kestrel won in FOUR ROUNDS by stealing BOTH treasures from the SAME
|
||||
victim (the nearest bot): two bankings = the win, and the second one
|
||||
simultaneously eliminated the victim via the captured-treasures
|
||||
clause. Robbing one player twice beats robbing two players once —
|
||||
same points, one fewer opponent, and the victim's own corner becomes
|
||||
your supply depot. When choosing the second steal, weigh "same
|
||||
victim" heavily.
|
||||
- Also: never chain move commands blind in a script — the (6,6) nook
|
||||
refused all three exits and my pipeline barreled into endTurn,
|
||||
burning a full turn standing still. Verify each step's result, or at
|
||||
minimum stop the chain on the first refusal.
|
||||
|
||||
## Game-ten scars (the closest one)
|
||||
|
||||
- **The champion's carrier-breaker combo**: DROP OBJECT (chest falls at
|
||||
your feet) + ADRENALINE (second attack) + TELEPORT OPPONENT (you,
|
||||
exiled across the maze; the chest, his). A laden wizard within his
|
||||
cast range is a wizard about to be unpacked. Carry through his reach
|
||||
only with a real counteraction in hand — and VERIFY a card actually
|
||||
counters before building your defense on it (NO SPELL is a silencing
|
||||
curse, not a counter; I learned mid-stack).
|
||||
- **He ferries his own death warrant**: when the elimination clause
|
||||
hung on his floor chest, he simply picked his route so the chest
|
||||
traveled with him. Deny-by-custody. Do the same: when MY vulnerable
|
||||
treasure lies on my path, take it along.
|
||||
- Sector relocation can REWRITE the whole world mid-game — arms move,
|
||||
warps rewire, routes invert. After any rotate/relocate event, throw
|
||||
away every cached route and re-run the router.
|
||||
- Eric's deeper frame: on wrap boards (especially 3- and 5-sector
|
||||
layouts) RELOCATE SECTOR is a TOPOLOGY WEAPON — one shift breaks
|
||||
ALL the curved warps at once and "everything is just across the
|
||||
board." Its payload is everyone else's stale mental map: the caster
|
||||
knows the new sewing instantly. Hold it for the moment an enemy's
|
||||
plan leans on a wrap (a warp delivery lane, a rim escape), then cut
|
||||
the seam. Conversely, never build a plan whose ONLY road is a warp
|
||||
while an opponent might hold this card.
|
||||
|
||||
## The board is a torus (from Eric, after game eleven)
|
||||
|
||||
- The warping rims mean there is NO center and NO edge: take the right
|
||||
column and sew it onto the left — identical geography. "Central" and
|
||||
"peripheral" seats are rendering artifacts of where the map unrolls.
|
||||
Thinking in those terms is a trap that limits your options (his
|
||||
words). Judge NOTHING by coordinates: distance is graph distance,
|
||||
threat is graph threat, and the router (BFS over edges + warps) is
|
||||
the only geometry that exists. Mentally re-unroll the map from
|
||||
wherever you stand — every wizard is the center of their own map.
|
||||
- BUT salience is Euclidean even when geometry is not (Eric): humans
|
||||
see "center tiles" and aim their aggression there, so the
|
||||
center-perceived seat pays an attention tax — and a strong player
|
||||
turns it into arbitrage: absorb the squabble, footrace out and back
|
||||
through wraps that make you secretly equidistant from everything.
|
||||
Play the graph for movement, the salience for predicting humans;
|
||||
expect neither bias nor exploitability from the bots — their BFS is
|
||||
natively non-Euclidean.
|
||||
|
||||
## Game-thirteen scars (the goat)
|
||||
|
||||
- **BUTT-HEAD is a cross-map physical nuke**: damage = the full walked
|
||||
distance of the charge, and it found 8 points across half a maze.
|
||||
ABSORB SPELL answers spells ONLY — at low life with no teleport in
|
||||
hand, there is NO answer to big physical; your only defenses are
|
||||
position and never being worth the charge. Audit the hand for
|
||||
physical answers separately from spell answers.
|
||||
- He parks his OWN chest on open floor when he needs free arms —
|
||||
custody is a dial, not a state. Watch every drop: grounded chests
|
||||
are targets again the moment they land.
|
||||
- My game-one wall and his one dust cloud shaped the ENTIRE game:
|
||||
single-square terrain on a junction outlives every spell cast near
|
||||
it. Place terrain at junctions, not corridors.
|
||||
|
||||
## Game-fourteen scars (the five-player raid race)
|
||||
|
||||
- **Terrain is SOFT in tempo math.** I counted a thornbush and an ooze
|
||||
square as two turns of delay on his banking route; DISPEL CREATION
|
||||
removed the bush for one card and zero steps, and he banked that
|
||||
same turn. Any creation (bush, ooze, wall of stone, stones) is one
|
||||
card from gone — count it as HALF a turn of delay, never a wall.
|
||||
- On wide boards (5-6p cross), a raider in my quadrant carrying my
|
||||
chest is usually UNREACHABLE by anything in hand — the interception
|
||||
had to happen a turn earlier, at the grab, not the haul. Defend the
|
||||
chest square, not the exit route.
|
||||
- My real boobytrap finally fired (4 damage to a warping bot) — traps
|
||||
on WARP LANDING squares hit players who never see the token coming.
|
||||
That placement doctrine is proven; repeat it.
|
||||
|
||||
## The passivity audit (Eric, after game thirteen)
|
||||
|
||||
- "You did nothing but draw for several turns" — true, and fatal. A
|
||||
turn that only draws is a LOST turn. When genuinely walled: probe.
|
||||
Dust-stagger attempts, LOS probes, wall punches, terrain casts —
|
||||
motion generates dice, information, and pressure; stillness
|
||||
generates nothing and hands the opponent free repositioning. The
|
||||
round-8 expedition that nearly won game thirteen was available in
|
||||
round 5. If two consecutive turns end in draw-only, something is
|
||||
wrong with the plan, not the maze.
|
||||
|
||||
## The board is the truth (I keep paying for this one)
|
||||
|
||||
- NEVER narrate a persistent effect without re-verifying it in the
|
||||
CURRENT view. My stones get dispelled, my walls get melted, my
|
||||
theories get invented. Before crediting any creation, curse, or
|
||||
blockade in reasoning OR table talk, check the CONTENTS and
|
||||
SUSTAINED lines and the map glyphs THIS turn. Kestrel quietly
|
||||
removes my infrastructure and lets my own commentary reveal that I
|
||||
haven't noticed.
|
||||
- Corollary: every "go" starts with reading the events tail for what
|
||||
the OTHERS did to the board, not just where they stand. A dispel,
|
||||
a stone-to-water, or a rotate-sector in that tail invalidates last
|
||||
turn's map in my head.
|
||||
|
||||
## Playing well
|
||||
|
||||
- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for
|
||||
@@ -128,8 +295,41 @@ probing legality is free, so when unsure, try it and read the error.
|
||||
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.
|
||||
- OPSEC round two (game six, violated AGAIN): analysis spoken aloud IS a
|
||||
card shown. Quoting a card's text with delight ("moves a piece to ANY
|
||||
square!") told him exactly what I held and what it does. Card-text
|
||||
lookups for cards in MY hand happen silently; my visible messages may
|
||||
discuss only what is already public on the board or in the chronicle.
|
||||
Even asking rules questions aloud about a specific card is a tell.
|
||||
- OPSEC round three (game fourteen): METAPHORS COUNT. "A door my pick
|
||||
answers" and "key ready for tomorrow" revealed a pick-lock as surely
|
||||
as naming it. The rule in its final form: table prose may reference
|
||||
ONLY public state — positions, banked counts, events already in the
|
||||
chronicle. Nothing about my hand, capabilities, or intentions, in
|
||||
any costume. When in doubt, describe the BOARD, never my options.
|
||||
- Send `chat` for table talk at dramatic moments; it's half the fun.
|
||||
|
||||
## The turn poller (no more idling)
|
||||
|
||||
After ending each turn (or whenever waiting on others), launch the
|
||||
watcher in the background so the harness wakes you the moment the
|
||||
game needs you — your turn or a counteraction window:
|
||||
|
||||
```
|
||||
node <scratchpad>/my-turn-poller.mjs # run_in_background: true
|
||||
```
|
||||
|
||||
(Script lives in the session scratchpad; recreate from the skill's
|
||||
memory if missing: it joins with the saved seat token, watches state
|
||||
messages, and exits with "WAITING ON CLAUDE: <reason>".) Eric asked
|
||||
for this after game eight sat waiting on me — don't make him prompt
|
||||
"your turn" ever again.
|
||||
|
||||
CRITICAL: launch it ONLY with the harness's run_in_background flag —
|
||||
that is what wakes you when it exits. A shell `&` inside a normal
|
||||
command detaches it into the void and nobody hears it fire (game
|
||||
eight, twice). One poller per wait; it exits when the game needs you.
|
||||
|
||||
## Cadence
|
||||
|
||||
1. On "go": `view`, read HIS events since your last turn (the client
|
||||
|
||||
@@ -586,7 +586,7 @@ function pathToward(
|
||||
view: GameView,
|
||||
from: Cell,
|
||||
goals: Set<string>,
|
||||
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {},
|
||||
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean; throughJails?: boolean } = {},
|
||||
): PathResult | null {
|
||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||
const enemyCells = livingEnemies(view).map((p) => p.position);
|
||||
@@ -611,9 +611,14 @@ function pathToward(
|
||||
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
||||
if (goals.has(k)) { found = k; break; }
|
||||
// Hazards end a clean path but are waded through when asked to.
|
||||
// THORNBUSH is never a wade: entering ends the turn, eats the NEXT
|
||||
// turn, and stings — a jail with foliage. Only a wizard with no
|
||||
// road at all (throughJails) dives in. "Few will dive into one
|
||||
// unless they are desperate."
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (hazard === "thornbush" && !opts.throughJails) continue;
|
||||
if (!opts.throughHazards &&
|
||||
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
(hazard === "pit" || hazard === "ooze" ||
|
||||
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
|
||||
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||
next.push(to);
|
||||
@@ -770,6 +775,16 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
const atk = attackId ? ATTACKS[attackId] : null;
|
||||
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||
|
||||
// A tear at the treasure in my arms outranks its zero damage: the stake
|
||||
// is the chest, not the bruise. The grab is PHYSICAL — shields part
|
||||
// around it; the only real answer is to not be there when it closes.
|
||||
if (stack.tearTreasure && me(view).carriedTreasureId != null) {
|
||||
const tp = find("teleport");
|
||||
const out = tp ? escapeCell(view, me(view).position) : null;
|
||||
if (tp && out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
if (attackId && UTILITY_ATTACKS.has(attackId)) {
|
||||
const stopper = find("full-shield") ?? find("force-field");
|
||||
const stakes =
|
||||
@@ -1055,12 +1070,19 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
}
|
||||
}
|
||||
if (!tier.buffs) return null; // the apprentice's book ends at the stones
|
||||
// A curse on the clockwork gets scrubbed off.
|
||||
const cursed = view.sustained.some(
|
||||
// A curse on the clockwork gets scrubbed off. The engine wants the
|
||||
// whole prescription — patient and ailment both — or it refuses the cast.
|
||||
const myCurse = view.sustained.find(
|
||||
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
|
||||
);
|
||||
const cleanse = inHand(view, "remove-curse");
|
||||
if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId };
|
||||
if (myCurse && cleanse) {
|
||||
return {
|
||||
type: "cast", instanceId: cleanse.instanceId,
|
||||
target: { kind: "player", playerId: view.you },
|
||||
params: { cardId: myCurse.cardId },
|
||||
};
|
||||
}
|
||||
// Three or more wizards: LIFESAVER takes elimination off the table.
|
||||
const lifesaver = inHand(view, "lifesaver");
|
||||
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
|
||||
@@ -1100,6 +1122,19 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
// wizard hauling gold, anyone bleeding out.
|
||||
const pressed = enemyNear &&
|
||||
(style === "worrier" || self.carriedTreasureId != null || self.life <= 5);
|
||||
// MIST-BODY turns a pressed carrier into an untouchable delivery — no
|
||||
// attack, drop, exile, or thief finds fog. Any temperament takes this
|
||||
// armor, and the biggest number rides it so the whole haul fits inside.
|
||||
if (pressed && self.carriedTreasureId) {
|
||||
const fog = inHand(view, "mist-body");
|
||||
if (fog && !view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === view.you)) {
|
||||
const biggest = numbersInHand(view).at(-1);
|
||||
return {
|
||||
type: "cast", instanceId: fog.instanceId,
|
||||
...(biggest ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (pressed && style !== "berserker") {
|
||||
// FEAR keeps every pursuer three spaces off.
|
||||
const scare = inHand(view, "fear");
|
||||
@@ -1367,6 +1402,22 @@ export function automatonCommand(
|
||||
};
|
||||
}
|
||||
}
|
||||
// LOCK IN PLACE roots a delivery mid-stride: a carrier closing on
|
||||
// their home loses a whole turn of legs and warp-steps.
|
||||
{
|
||||
const root = inHand(view, "lock-in-place");
|
||||
const runner = root ? visible.find((p) =>
|
||||
p.carriedTreasureId != null &&
|
||||
Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 7) : undefined;
|
||||
if (root && runner) {
|
||||
const smallest = numbersInHand(view)[0];
|
||||
return {
|
||||
type: "cast", instanceId: root.instanceId,
|
||||
target: { kind: "player", playerId: runner.id },
|
||||
...(smallest ? { numberInstanceIds: [smallest.instanceId] } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (tier.buffs && !view.turn.attackUsed) {
|
||||
const smallest = numbersInHand(view)[0];
|
||||
// ADRENALINE: when no single blow finishes the target but two would.
|
||||
@@ -1541,7 +1592,9 @@ export function automatonCommand(
|
||||
// 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;
|
||||
// A carrier never chases: the delivery in hand outranks the gold in
|
||||
// someone else's.
|
||||
const chasing = thief !== null && strikeLive && !self.carriedTreasureId;
|
||||
// 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.
|
||||
@@ -1565,11 +1618,17 @@ export function automatonCommand(
|
||||
const path =
|
||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
|
||||
pathToward(view, self.position, objectives, { canUnlock }) ??
|
||||
// The true objective, hazards waded, comes before any generic march
|
||||
// on enemies: one thornbush on the road must not reroute the whole
|
||||
// campaign toward whoever happens to have a clean corridor.
|
||||
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true }) ??
|
||||
pathToward(view, self.position, enemyCells, { canUnlock }) ??
|
||||
// No clean road to anything: grit the teeth and wade the hazards,
|
||||
// as any wizard would rather than stand in a pocket forever.
|
||||
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true }) ??
|
||||
pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true });
|
||||
pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true }) ??
|
||||
// And only when even the hazard roads are severed does desperation
|
||||
// dive through a thornbush jail.
|
||||
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true, throughJails: true });
|
||||
// A shimmering illusion on the best crossing costs nothing to doubt:
|
||||
// roll the free test before spending any card on that wall.
|
||||
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
||||
|
||||
@@ -480,3 +480,34 @@ export function sightBetween(
|
||||
return hasLineOfSight(board, from, to, blockedCells) ||
|
||||
hasWarpLineOfSight(board, from, to, blockedCells);
|
||||
}
|
||||
|
||||
/**
|
||||
* AROUND THE CORNER's full reach: "around one corner (up to 180 degrees)".
|
||||
* A hairpin around the end of a wall pivots IN the wall's gap, not at any
|
||||
* cell's center — so try the midpoint of every open edge as the bend point,
|
||||
* with a clear straight segment from caster to pivot and pivot to target.
|
||||
*/
|
||||
export function bentSightThroughGap(
|
||||
board: AssembledBoard,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): boolean {
|
||||
const fx = from.x + 0.5, fy = from.y + 0.5;
|
||||
const tx = to.x + 0.5, ty = to.y + 0.5;
|
||||
const skip = [cellKey(from), cellKey(to)];
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const c = { x, y };
|
||||
for (const side of ["E", "S"] as Side[]) {
|
||||
if (!board.cells[cellKey(neighbor(c, side))]) continue;
|
||||
if (edgeState(board, c, side) !== "open") continue;
|
||||
const [px, py] = side === "E" ? [x + 1, y + 0.5] : [x + 0.5, y + 1];
|
||||
if (segmentClear(board, fx, fy, px, py, blockedCells, skip) &&
|
||||
segmentClear(board, px, py, tx, ty, blockedCells, skip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+379
-85
@@ -24,6 +24,7 @@ import {
|
||||
type Warp,
|
||||
hasLineOfSight,
|
||||
sightBetween,
|
||||
bentSightThroughGap,
|
||||
neighbor,
|
||||
stepTarget,
|
||||
opposite,
|
||||
@@ -211,6 +212,12 @@ export interface CastStack {
|
||||
creatureTouch?: "wraith" | "claw";
|
||||
/** A spell freed from slime: counteractions cannot touch its caster. */
|
||||
trapped?: boolean;
|
||||
/** STRENGTH's tear: "this would be an attack" — the grab rides the stack
|
||||
* like a punch, and the clutch roll waits for the counteractions. */
|
||||
tearTreasure?: true;
|
||||
/** What a cardless stack's blow is called in the chronicle (the WARD's
|
||||
* bite); without it the source falls back to "punch from <attacker>". */
|
||||
sourceName?: string;
|
||||
}
|
||||
|
||||
export interface CastParams {
|
||||
@@ -229,7 +236,7 @@ export interface CastParams {
|
||||
}
|
||||
|
||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||
export const CURRENT_RULES_REV = 5;
|
||||
export const CURRENT_RULES_REV = 11;
|
||||
|
||||
export interface GameConfig {
|
||||
playerIds: PlayerId[];
|
||||
@@ -253,6 +260,24 @@ export interface GameConfig {
|
||||
* it; unpassed, it relocks at turn's end as before.
|
||||
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
|
||||
* in three spaces, instead of eating the card silently at resolution.
|
||||
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this
|
||||
* would be an attack") instead of resolving instantly.
|
||||
* Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the
|
||||
* caster carries it, sharing a square bites both directions, no
|
||||
* counteraction.
|
||||
* Rev 8: the fire imp's scorch is a SPELL (FAQ) — counteractable,
|
||||
* magical — and SOULSTONE's floor holds even at 3 life or below.
|
||||
* Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ...
|
||||
* otherwise work as written"), though nothing a counter does touches
|
||||
* its caster.
|
||||
* Rev 10: BUTT-HEAD's ram is MOVEMENT — the charge walks real
|
||||
* corridors on the turn's legal legs (three plus numbers) and spends
|
||||
* them.
|
||||
* Rev 11: a REVERSE against SLOW DEATH or WALKING DEAD turns the whole
|
||||
* curse (FAQ) — a point gained per card drawn, half a point per space
|
||||
* walked, permanently — and a FULL REFLECTION returns a permanent
|
||||
* curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of
|
||||
* letting it evaporate.
|
||||
*/
|
||||
deckRev?: number;
|
||||
}
|
||||
@@ -388,21 +413,25 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
|
||||
return edges ? { ...board, edges } : board;
|
||||
}
|
||||
|
||||
/** LOS including square-filling blockers. */
|
||||
/** Sight granted by removing any ONE closed edge — the VISIONSTONE's
|
||||
* whole power, shared by every path that honors it. */
|
||||
function sightThroughOneEdge(
|
||||
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
|
||||
): boolean {
|
||||
* whole power, shared by every path that honors it. `sees` judges the
|
||||
* board with the candidate edge gone. */
|
||||
function throughOneEdge(board: AssembledBoard, sees: (b: AssembledBoard) => boolean): boolean {
|
||||
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;
|
||||
if (sees({ ...board, edges })) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sightThroughOneEdge(
|
||||
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
|
||||
): boolean {
|
||||
return throughOneEdge(board, (b) => sightBetween(b, from, to, blockers));
|
||||
}
|
||||
|
||||
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
|
||||
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
||||
const blockers = losBlockers(state);
|
||||
@@ -483,8 +512,10 @@ function casterLos(
|
||||
return sightThroughOneEdge(board, from, to, blockers);
|
||||
}
|
||||
|
||||
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
||||
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean {
|
||||
/** Bent LOS for AROUND THE CORNER: the caster sees a middle cell which sees
|
||||
* the target — or, for the full 180-degree hairpin, the sight line doubles
|
||||
* back around a wall's end, pivoting in the open gap itself. */
|
||||
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean {
|
||||
if (casterLos(state, caster, from, to)) return true;
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
@@ -494,6 +525,49 @@ function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, ev
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
|
||||
return bentSightThroughGap(board, from, to, losBlockers(state));
|
||||
}
|
||||
|
||||
/** Sight for a square-aimed cast. "You may cast any line of sight spell
|
||||
* around one corner (up to 180 degrees)" — the attachment bends every
|
||||
* L.O.S. spell, creations and utilities included, not just attacks. */
|
||||
function castSight(
|
||||
state: GameState,
|
||||
caster: PlayerState,
|
||||
cmd: Extract<Command, { type: "cast" }>,
|
||||
to: Cell,
|
||||
): boolean {
|
||||
if (gameLos(state, caster.position, to, caster.id)) return true;
|
||||
return !!cmd.aroundCornerInstanceId && bentLos(state, caster, caster.position, to);
|
||||
}
|
||||
|
||||
/** Sight for an edge-aimed cast (walls, doors), bend-aware like castSight.
|
||||
* VISIONSTONE is the bearer's sight here too — one wall or door, any one,
|
||||
* falls away for the look, exactly as it does for square-aimed casts. */
|
||||
function castSightEdge(
|
||||
state: GameState,
|
||||
caster: PlayerState,
|
||||
cmd: Extract<Command, { type: "cast" }>,
|
||||
board: AssembledBoard,
|
||||
cell: Cell,
|
||||
side: Side,
|
||||
): boolean {
|
||||
if (losToEdge(board, caster.position, cell, side)) return true;
|
||||
if (displays(caster, "visionstone") &&
|
||||
throughOneEdge(board, (b) => losToEdge(b, caster.position, cell, side))) {
|
||||
return true;
|
||||
}
|
||||
if (!cmd.aroundCornerInstanceId) return false;
|
||||
// The bend pivots in a seen middle SQUARE; the gap-pivot hairpin is
|
||||
// square-to-square geometry, so an edge target gets no 180-degree bend.
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if (casterLos(state, caster, caster.position, mid) && losToEdge(board, mid, cell, side)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -544,11 +618,11 @@ export type GameEvent =
|
||||
| { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string }
|
||||
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
|
||||
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
|
||||
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" }
|
||||
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" }
|
||||
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean }
|
||||
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
|
||||
soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] }
|
||||
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" }
|
||||
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" }
|
||||
| { type: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number }
|
||||
| { type: "stunned"; player: PlayerId; turnsLost: number }
|
||||
| { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number }
|
||||
@@ -619,6 +693,7 @@ export type GameEvent =
|
||||
| { type: "stuckInSlime"; player: PlayerId; at: Cell }
|
||||
| { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] }
|
||||
| { type: "boobytrapSprung"; player: PlayerId; at: Cell }
|
||||
| { type: "boobytrapBlank"; player: PlayerId; at: Cell }
|
||||
| { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell }
|
||||
| { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number }
|
||||
| { type: "safeCreated"; caster: PlayerId; at: Cell }
|
||||
@@ -670,6 +745,8 @@ export type GameEvent =
|
||||
| { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell }
|
||||
| { type: "treasureTorn"; attacker: PlayerId; defender: PlayerId; treasureId: string; toFloor: boolean }
|
||||
| { type: "tearResisted"; attacker: PlayerId; defender: PlayerId }
|
||||
| { type: "treasureTearAttempted"; attacker: PlayerId; defender: PlayerId }
|
||||
| { type: "powerstoneBoost"; player: PlayerId; newAllowance: number }
|
||||
| { type: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null }
|
||||
| { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" }
|
||||
| { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] }
|
||||
@@ -755,6 +832,10 @@ type AttackEffect = {
|
||||
baseDamage: (numberValue: number | null, params: CastParams | null) => number;
|
||||
/** A duration spell: attach a sustained effect on resolution. */
|
||||
sustains?: boolean;
|
||||
/** A permanent curse attached by onResolved (SLOW DEATH, WALKING DEAD,
|
||||
* IDIOT): it rides no damage or duration pipe, so a FULL REFLECTION
|
||||
* must return it explicitly or the curse would evaporate. */
|
||||
permanentCurse?: true;
|
||||
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
|
||||
keepInHand?: boolean;
|
||||
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
|
||||
@@ -795,6 +876,8 @@ interface ResolutionContext {
|
||||
defender: PlayerState;
|
||||
damageDealt: number;
|
||||
fullyStopped: boolean;
|
||||
/** A surviving REVERSE rode the stack: the spell's effect runs backward. */
|
||||
reversed: boolean;
|
||||
duration: number;
|
||||
stack: CastStack;
|
||||
}
|
||||
@@ -1163,7 +1246,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
}
|
||||
const key = edgeKey(cell, side);
|
||||
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall line";
|
||||
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight to the wall line";
|
||||
state.edgeOverrides[key] = "wall";
|
||||
state.createdEdges[key] = true;
|
||||
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
||||
@@ -1187,7 +1270,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const key = edgeKey(cell, side);
|
||||
const current = view.edges[key] ?? "open";
|
||||
if (current === "open") return "there is no wall there";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall";
|
||||
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight to the wall";
|
||||
state.edgeOverrides[key] = "open";
|
||||
delete state.doorStates[key];
|
||||
delete state.createdEdges[key];
|
||||
@@ -1376,7 +1459,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, target.position)) return "no line of sight";
|
||||
// Effectively permanent: broken by the caster attacking the target.
|
||||
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
||||
return null;
|
||||
@@ -1415,8 +1498,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
ctx.defender.carriedTreasureId = null;
|
||||
ctx.events.push({
|
||||
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
|
||||
at: ctx.defender.position, onHomeOf: null,
|
||||
at: ctx.defender.position, onHomeOf: homeOwnerAt(ctx.state, ctx.defender.position),
|
||||
});
|
||||
// A forced drop can land on a home square: bankings and the
|
||||
// captured-treasures clause take effect at once, as they would
|
||||
// for a willing drop.
|
||||
checkVictory(ctx.state, ctx.events);
|
||||
return;
|
||||
}
|
||||
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
|
||||
@@ -1475,7 +1562,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (farKey && (view.edges[farKey] ?? "open") !== "open") {
|
||||
return "the far mouth of that warp is sealed";
|
||||
}
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
|
||||
const ignite = (k: string, at: { cell: Cell; side: Side }) => {
|
||||
state.edgeOverrides[k] = "firewall";
|
||||
state.createdEdges[k] = true;
|
||||
@@ -1511,7 +1598,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
||||
return "the wave must span a corridor between two spaces";
|
||||
}
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
|
||||
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
|
||||
// The two sides of the edge, and the push directions away from it.
|
||||
waveFromEdge(state, events, cell, side, 2, "waterwall");
|
||||
@@ -1526,13 +1613,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (cmd.target?.kind === "edge") {
|
||||
const key = edgeKey(cmd.target.cell, cmd.target.side);
|
||||
if (state.illusionWalls[key]) {
|
||||
if (!losToEdge(boardView(state), caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
|
||||
if (!castSightEdge(state, caster, cmd, boardView(state), cmd.target.cell, cmd.target.side)) return "no line of sight";
|
||||
delete state.illusionWalls[key];
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
|
||||
return null;
|
||||
}
|
||||
if (!state.createdEdges[key]) return "that is not a created thing";
|
||||
if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
|
||||
if (!castSightEdge(state, caster, cmd, view, cmd.target.cell, cmd.target.side)) return "no line of sight";
|
||||
const was = view.edges[key];
|
||||
delete state.edgeOverrides[key];
|
||||
delete state.createdEdges[key];
|
||||
@@ -1544,14 +1631,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const key = cellKey(cmd.target.cell);
|
||||
const creature = creatureAt(state, cmd.target.cell);
|
||||
if (creature) {
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||
destroyCreature(state, events, creature, "dispel creation");
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
||||
return null;
|
||||
}
|
||||
const content = state.squareContents[key];
|
||||
if (!content) return "nothing created there";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||
delete state.squareContents[key];
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
||||
return null;
|
||||
@@ -1568,7 +1655,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (target.id === caster.id) return "you cannot drag yourself";
|
||||
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, target.position)) return "no line of sight";
|
||||
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
||||
const from = target.position;
|
||||
dragToward(state, target, caster.position);
|
||||
@@ -1577,7 +1664,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
}
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const key = cellKey(cmd.target.cell);
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||
const objects = state.groundObjects[key];
|
||||
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
||||
if (objects && objects.length > 0) {
|
||||
@@ -1603,7 +1690,15 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
},
|
||||
// --- Magic stones ---------------------------------------------------------
|
||||
bloodstone: stoneEffect(),
|
||||
powerstone: stoneEffect(),
|
||||
// "Add 1 to any NUMBER card played." On the table the stone and the
|
||||
// number land together; digitally the display may come a beat late, so
|
||||
// it blesses a number already played for movement this turn.
|
||||
powerstone: stoneEffect((state, events, caster) => {
|
||||
if (activePlayer(state).id === caster.id && state.turn.numberPlayedForMovement) {
|
||||
state.turn.movementAllowance += 1;
|
||||
events.push({ type: "powerstoneBoost", player: caster.id, newAllowance: state.turn.movementAllowance });
|
||||
}
|
||||
}),
|
||||
shadowstone: stoneEffect(),
|
||||
soulstone: stoneEffect(),
|
||||
speedstone: stoneEffect((state, _events, caster) => {
|
||||
@@ -1635,17 +1730,23 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
baseDamage: () => 0,
|
||||
permanentCurse: true,
|
||||
// "This is permanent. Once SLOW DEATH is on, it can't be turned off."
|
||||
// Rev 11, per the FAQ: "If SLOW DEATH is REVERSED, the player receives
|
||||
// a point for every card drawn" — the whole curse attaches backward,
|
||||
// just as permanently. Older games attached it biting; they replay so.
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
||||
const reversed = ctx.reversed && (ctx.state.config.deckRev ?? 1) >= 11;
|
||||
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id,
|
||||
PERMANENT_TURNS, reversed ? { reversed: 1 } : {});
|
||||
},
|
||||
},
|
||||
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
|
||||
"around-the-corner": {
|
||||
kind: "neutral",
|
||||
// Never cast alone — attached to an attack via aroundCornerInstanceId.
|
||||
resolve: () => "attach Around The Corner to an attack instead of casting it alone",
|
||||
// Never cast alone — attached to any L.O.S. cast via aroundCornerInstanceId.
|
||||
resolve: () => "attach Around The Corner to a line-of-sight spell instead of casting it alone",
|
||||
},
|
||||
ugly: {
|
||||
kind: "neutral",
|
||||
@@ -1678,7 +1779,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const key = edgeKey(cell, side);
|
||||
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
|
||||
if (state.illusionWalls[key]) return "an illusion already shimmers there";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
|
||||
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
|
||||
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
|
||||
return null;
|
||||
@@ -1750,7 +1851,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||
if (!creature) return "no such monster";
|
||||
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
|
||||
if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, creature.position)) return "no line of sight";
|
||||
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
|
||||
if (boost === "movement") creature.movesPerTurn *= 2;
|
||||
else creature.maxDamage *= 2;
|
||||
@@ -1841,7 +1942,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const key = edgeKey(cell, side);
|
||||
const view = boardView(state);
|
||||
if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
|
||||
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
|
||||
state.edgeOverrides[key] = "open";
|
||||
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
|
||||
@@ -1917,7 +2018,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
|
||||
return "a door goes into a stone wall or an open corridor";
|
||||
}
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
|
||||
state.edgeOverrides[key] = "door";
|
||||
state.createdEdges[key] = true;
|
||||
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
||||
@@ -1937,8 +2038,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
}
|
||||
const uniq = new Set(cells.map(cellKey));
|
||||
if (uniq.size !== 4) return "the four tokens go on four different squares";
|
||||
state.boobytraps.push({ casterId: caster.id, cells: [...cells], realKey: cellKey(cells[0]!) });
|
||||
events.push({ type: "boobytrapPlaced", caster: caster.id, cells: [...cells] });
|
||||
// The FIRST cell in the command is the real trap — but that truth
|
||||
// lives only in realKey. Stored and broadcast in canonical order,
|
||||
// the tokens carry no tell: position must never whisper which one
|
||||
// bites.
|
||||
const laid = [...cells].sort((a, b) => cellKey(a).localeCompare(cellKey(b)));
|
||||
state.boobytraps.push({ casterId: caster.id, cells: laid, realKey: cellKey(cells[0]!) });
|
||||
events.push({ type: "boobytrapPlaced", caster: caster.id, cells: laid.map((c) => ({ ...c })) });
|
||||
events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! });
|
||||
return null;
|
||||
},
|
||||
@@ -1952,7 +2058,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
(state.groundObjects[key] ?? []).length > 0 ||
|
||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||
if (!hasObject) return "there is nothing there to glue down";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||
state.gluedCells[key] = true;
|
||||
// "a duration equal to twice the NUMBER card played"
|
||||
const turns = magnitude.duration * 2;
|
||||
@@ -1976,7 +2082,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
(state.groundObjects[key] ?? []).length > 0 ||
|
||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||
if (!hasObject) return "there is nothing there to lock up";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
|
||||
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
||||
return null;
|
||||
@@ -1994,7 +2100,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ka === kb) return "pick two different squares";
|
||||
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
||||
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
|
||||
if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, a) || !castSight(state, caster, cmd, b)) return "no line of sight";
|
||||
const itemsA = state.groundObjects[ka] ?? [];
|
||||
const itemsB = state.groundObjects[kb] ?? [];
|
||||
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
||||
@@ -2026,7 +2132,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const current = view.edges[key];
|
||||
const meltable = current === "wall" || current === "door";
|
||||
if (!meltable) return "that is not a stone wall";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
|
||||
state.edgeOverrides[key] = "open";
|
||||
delete state.doorStates[key];
|
||||
delete state.createdEdges[key];
|
||||
@@ -2057,7 +2163,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const key = cellKey(cmd.target.cell);
|
||||
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||
delete state.squareContents[key];
|
||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
||||
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
|
||||
@@ -2115,13 +2221,21 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
baseDamage: () => 0,
|
||||
permanentCurse: true,
|
||||
// "1/2 point of damage for every space moved. This spell is permanent."
|
||||
// Rev 11: REVERSED at cast, the walk heals instead — half a point
|
||||
// gained per space, as permanently. Older games attached it biting.
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
||||
const reversed = ctx.reversed && (ctx.state.config.deckRev ?? 1) >= 11;
|
||||
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id,
|
||||
PERMANENT_TURNS, reversed ? { reversed: 1 } : {});
|
||||
},
|
||||
},
|
||||
disease: {
|
||||
// Legacy (rev ≤6): cast AT an adjacent victim through the attack
|
||||
// stack; ledgers from those games replay this path. Rev 7 games
|
||||
// never reach it — doCast intercepts disease as a self-cast plague.
|
||||
kind: "attack",
|
||||
baseDamage: () => 0,
|
||||
sameSquare: false,
|
||||
@@ -2208,8 +2322,35 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
kind: "attack",
|
||||
physical: true,
|
||||
baseDamage: () => 0, // computed at resolution: distance rammed
|
||||
// Rev 10, per the table: the ram IS movement — the goat charges on
|
||||
// its legal legs (three plus numbers played), walking real corridors.
|
||||
// Earlier revisions rammed at any range; their ledgers keep it.
|
||||
validate: (state, _cmd, _caster, target) => {
|
||||
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
|
||||
const caster = activePlayer(state);
|
||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
||||
const d = walkingDistance(state, caster.position, target.position);
|
||||
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
|
||||
return null;
|
||||
},
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
||||
if ((ctx.state.config.deckRev ?? 1) >= 10) {
|
||||
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
|
||||
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position);
|
||||
if (d === 0) return;
|
||||
if (d > legs) {
|
||||
// The target slipped beyond the charge (a counter-teleport, say):
|
||||
// the goat pulls up short, winded, and the table sees it.
|
||||
ctx.events.push({ type: "attackMissed", attacker: ctx.attacker.id, defender: ctx.defender.id, attackCardId: "butt-head", because: "outran" });
|
||||
return;
|
||||
}
|
||||
ctx.state.turn.movementUsed += d;
|
||||
ctx.attacker.position = { ...ctx.defender.position };
|
||||
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
|
||||
resolvedBlow(ctx, d, "goat ram", "physical");
|
||||
return;
|
||||
}
|
||||
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
|
||||
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
|
||||
if (d === 0) return;
|
||||
@@ -2429,6 +2570,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
baseDamage: () => 0,
|
||||
permanentCurse: true,
|
||||
// "Opponent heads straight for the nearest of his own treasures ... This
|
||||
// lasts until opponent is on his own treasure."
|
||||
sustains: false,
|
||||
@@ -2441,6 +2583,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
|
||||
if (carried?.owner === ctx.defender.id) return;
|
||||
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
||||
// Already standing on their own treasure: the march is over as it begins.
|
||||
liftIdiotIfSatisfied(ctx.state, ctx.events, ctx.defender);
|
||||
},
|
||||
},
|
||||
"big-man": {
|
||||
@@ -2526,7 +2670,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const aim = cmd.target.cell;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(aim)]) return "off the board";
|
||||
if (!casterLos(state, caster, caster.position, aim)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, aim)) return "no line of sight";
|
||||
state.turn.attackUsed = true;
|
||||
|
||||
const clampToBoard = (c: Cell): Cell => {
|
||||
@@ -2758,7 +2902,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
|
||||
if (!view.cells[cellKey(at)]) return "off the board";
|
||||
if (state.squareContents[cellKey(at)]) return "that square is blocked";
|
||||
if (creatureAt(state, at)) return "a creature is already there";
|
||||
if (!casterLos(state, caster, caster.position, at)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, at)) return "no line of sight";
|
||||
state.turn.attackUsed = true;
|
||||
spawnCreature(state, events, kind, caster.id, at);
|
||||
return null;
|
||||
@@ -2810,7 +2954,7 @@ function emptySquareTarget(
|
||||
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
||||
return "the warp shimmers there — nothing can form on it";
|
||||
}
|
||||
if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight";
|
||||
if (!castSight(state, caster, cmd, cell)) return "no line of sight";
|
||||
return cell;
|
||||
}
|
||||
|
||||
@@ -2937,9 +3081,22 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
|
||||
if (!gameLos(state, imp.position, p.position)) continue;
|
||||
imp.scorchedThisTurn.push(p.id);
|
||||
events.push({ type: "impScorches", creatureId: imp.id, player: p.id });
|
||||
// Rev 8, per the FAQ: every imp attack is technically a spell — the
|
||||
// scorch opens a counteraction window and burns as MAGICAL fire
|
||||
// (soulstone's floor holds). One victim's window at a time; the
|
||||
// sweep finds the rest on its next pass. Older games keep the
|
||||
// instant kindless burn their ledgers recorded.
|
||||
if ((state.config.deckRev ?? 1) >= 8) {
|
||||
if (!state.stack) {
|
||||
openCreatureStack(state, imp, p, 2, undefined, "spell");
|
||||
break;
|
||||
}
|
||||
imp.scorchedThisTurn.pop(); // window busy: burn on a later sweep
|
||||
} else {
|
||||
applyDamage(state, events, p, 2, "fire imp", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
checkVictory(state, events);
|
||||
}
|
||||
|
||||
@@ -3095,21 +3252,35 @@ function openCreatureStack(
|
||||
victim: PlayerState,
|
||||
damage: number,
|
||||
touch?: "wraith" | "claw",
|
||||
kind: "physical" | "spell" = "physical",
|
||||
): void {
|
||||
openCardlessStack(state, creature.controllerId, victim.id, {
|
||||
params: { damage },
|
||||
kind,
|
||||
creatureId: creature.id,
|
||||
...(touch ? { creatureTouch: touch } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** A counteraction stack with no attack card behind it — a creature's
|
||||
* blow, the WARD's bite, STRENGTH's tear. Counters ride it as usual. */
|
||||
function openCardlessStack(
|
||||
state: GameState,
|
||||
attackerId: PlayerId,
|
||||
defenderId: PlayerId,
|
||||
opts: Pick<CastStack, "params" | "kind"> & Partial<CastStack>,
|
||||
): void {
|
||||
state.stack = {
|
||||
attackerId: creature.controllerId,
|
||||
defenderId: victim.id,
|
||||
attackerId,
|
||||
defenderId,
|
||||
attackCard: null,
|
||||
numberValue: null,
|
||||
amplifyFactor: 1,
|
||||
extendFactor: 1,
|
||||
powerAttackPoints: 0,
|
||||
params: { damage },
|
||||
kind: "physical",
|
||||
counters: [],
|
||||
waitingOn: victim.id,
|
||||
creatureId: creature.id,
|
||||
...(touch ? { creatureTouch: touch } : {}),
|
||||
waitingOn: defenderId,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3441,6 +3612,7 @@ function attachSustained(
|
||||
casterId: PlayerId,
|
||||
targetId: PlayerId,
|
||||
turns: number,
|
||||
data: Record<string, number> = {},
|
||||
): void {
|
||||
const effect: SustainedEffect = {
|
||||
id: `fx-${state.nextEffectId++}`,
|
||||
@@ -3448,7 +3620,7 @@ function attachSustained(
|
||||
casterId,
|
||||
targetId,
|
||||
remainingTurns: Math.max(1, turns),
|
||||
data: {},
|
||||
data,
|
||||
};
|
||||
state.sustained.push(effect);
|
||||
events.push({
|
||||
@@ -4064,7 +4236,9 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
// Square contents at the destination.
|
||||
let content = state.squareContents[cellKey(p.position)];
|
||||
if (content?.kind === "stone") return err("that square is solid stone");
|
||||
// BIG MAN: nobody enters his square.
|
||||
// BIG MAN: nobody enters his square — except a SHRUNK wizard, small
|
||||
// enough to slip between the giant's boots (table ruling).
|
||||
if (sustainedOn(state, p.id, "shrink").length === 0) {
|
||||
for (const other of state.players) {
|
||||
if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) &&
|
||||
sustainedOn(state, other.id, "big-man").length > 0) {
|
||||
@@ -4072,6 +4246,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
return err("a giant fills that corridor");
|
||||
}
|
||||
}
|
||||
}
|
||||
// BIG MAN pushes: "You can push monsters or other players (in an adjacent
|
||||
// square) down the corridor ahead of you as you move". No
|
||||
// room to shove them onward means no way forward for the giant either.
|
||||
@@ -4148,14 +4323,20 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
state.turn.movementUsed++;
|
||||
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
||||
|
||||
// WALKING DEAD: 1/2 point per space moved (a full point every two steps).
|
||||
// WALKING DEAD: 1/2 point per space moved (a full point every two
|
||||
// steps) — gained instead of bled for a curse REVERSED at its casting.
|
||||
for (const fx of sustainedOn(state, p.id, "walking-dead")) {
|
||||
fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1;
|
||||
if (fx.data.halfSteps % 2 === 0) {
|
||||
if (fx.data.reversed) {
|
||||
p.life += 1;
|
||||
events.push({ type: "lifeGained", player: p.id, amount: 1, source: "walking dead (reversed)", lifeAfter: p.life });
|
||||
} else {
|
||||
applyDamage(state, events, p, 1, "walking dead", null);
|
||||
checkVictory(state, events);
|
||||
}
|
||||
}
|
||||
}
|
||||
// DISEASE: the carrier infects every other player in a square they enter.
|
||||
if (p.alive && sustainedOn(state, p.id, "disease").length > 0) {
|
||||
for (const other of state.players) {
|
||||
@@ -4165,6 +4346,18 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
}
|
||||
checkVictory(state, events);
|
||||
}
|
||||
// Rev 7: sharing a plague square cuts both ways — walking INTO a
|
||||
// carrier's square catches the disease's bite just the same.
|
||||
if ((state.config.deckRev ?? 1) >= 7 && p.alive) {
|
||||
for (const other of state.players) {
|
||||
if (!other.alive || other.id === p.id) continue;
|
||||
if (cellKey(other.position) !== cellKey(p.position)) continue;
|
||||
if (sustainedOn(state, other.id, "disease").length === 0) continue;
|
||||
applyDamage(state, events, p, 3, "disease", null, "physical");
|
||||
checkVictory(state, events);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (crossedFirewall) {
|
||||
events.push({ type: "firewallBurned", player: p.id });
|
||||
@@ -4226,31 +4419,25 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
state.turn.actionsEnded = true;
|
||||
springSlimeTrap(state, events, p);
|
||||
}
|
||||
// IDIOT lifts when the victim reaches their own treasure.
|
||||
if (sustainedOn(state, p.id, "idiot").length > 0) {
|
||||
const onOwn = state.treasures.some(
|
||||
(t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position),
|
||||
);
|
||||
const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position);
|
||||
if (onOwn || allCarried) {
|
||||
for (const fx of sustainedOn(state, p.id, "idiot")) {
|
||||
events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id });
|
||||
}
|
||||
state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id));
|
||||
}
|
||||
}
|
||||
liftIdiotIfSatisfied(state, events, p);
|
||||
|
||||
// Armed ambushes may spring on this step.
|
||||
checkAmbushes(state, events, p, { movedFrom: from });
|
||||
|
||||
// BOOBYTRAP: the real token detonates under anyone but its caster.
|
||||
// BOOBYTRAP: the real token detonates under anyone but its caster; a
|
||||
// blank token is flipped by the step that finds it and leaves the board
|
||||
// alone — each decoy dies its own death.
|
||||
for (const trap of [...state.boobytraps]) {
|
||||
if (trap.casterId === p.id) continue;
|
||||
if (cellKey(p.position) === trap.realKey) {
|
||||
const here = cellKey(p.position);
|
||||
if (here === trap.realKey) {
|
||||
state.boobytraps = state.boobytraps.filter((t) => t !== trap);
|
||||
events.push({ type: "boobytrapSprung", player: p.id, at: p.position });
|
||||
applyDamage(state, events, p, 4, "boobytrap", null, "physical");
|
||||
checkVictory(state, events);
|
||||
} else if (trap.cells.some((c) => cellKey(c) === here)) {
|
||||
trap.cells = trap.cells.filter((c) => cellKey(c) !== here);
|
||||
events.push({ type: "boobytrapBlank", player: p.id, at: p.position });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4381,6 +4568,22 @@ function castingBlocked(
|
||||
* treasure on their own home, or drop their own treasure underfoot for an
|
||||
* instant cure.
|
||||
*/
|
||||
/** IDIOT lifts when the march is done — the victim stands on their own
|
||||
* treasure — or turns unsatisfiable: every one of their treasures is in
|
||||
* someone's arms, leaving nothing to stand on. */
|
||||
function liftIdiotIfSatisfied(state: GameState, events: GameEvent[], p: PlayerState): void {
|
||||
if (sustainedOn(state, p.id, "idiot").length === 0) return;
|
||||
const onOwn = state.treasures.some(
|
||||
(t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position),
|
||||
);
|
||||
const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position);
|
||||
if (!onOwn && !allCarried) return;
|
||||
for (const fx of sustainedOn(state, p.id, "idiot")) {
|
||||
events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id });
|
||||
}
|
||||
state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id));
|
||||
}
|
||||
|
||||
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||
if (sustainedOn(state, playerId, "idiot").length > 0) {
|
||||
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
||||
@@ -4460,9 +4663,19 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
||||
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
|
||||
state.discard.push(card!);
|
||||
events.push({ type: "wardSprung", owner: owner.id, victim: taker.id });
|
||||
// Rev 9: "COUNTERACTIONs against WARD ... otherwise work as written" —
|
||||
// the bite rides the stack. `trapped` carries the card's other clause:
|
||||
// nothing a counter does can touch the Ward's caster. Older games
|
||||
// recorded the instant bite and replay it.
|
||||
if ((state.config.deckRev ?? 1) >= 9) {
|
||||
openCardlessStack(state, owner.id, taker.id, {
|
||||
params: { damage: 3 }, kind: "spell", trapped: true, sourceName: "warded treasure",
|
||||
});
|
||||
} else {
|
||||
applyDamage(state, events, taker, 3, "warded treasure", null);
|
||||
checkVictory(state, events);
|
||||
}
|
||||
}
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
@@ -4579,10 +4792,31 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
|
||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||
state.turn.attackUsed = true;
|
||||
|
||||
// Rev 6: "this would be an attack" — the grab opens a counteraction
|
||||
// window like any punch; the clutch roll waits for the counters.
|
||||
if ((state.config.deckRev ?? 1) >= 6) {
|
||||
openCardlessStack(state, attacker.id, target.id, {
|
||||
params: null, kind: "physical", tearTreasure: true,
|
||||
});
|
||||
events.push({ type: "treasureTearAttempted", attacker: attacker.id, defender: target.id });
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
executeTear(state, events, attacker, target);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
/** The clutch roll and the wrestling itself, once nothing stands in the way. */
|
||||
function executeTear(
|
||||
state: GameState,
|
||||
events: GameEvent[],
|
||||
attacker: PlayerState,
|
||||
target: PlayerState,
|
||||
): void {
|
||||
const roll = rollD4(state, events, target.id, "clutching the treasure — a 1 keeps it");
|
||||
if (roll === 1) {
|
||||
events.push({ type: "tearResisted", attacker: attacker.id, defender: target.id });
|
||||
return { ok: true, state, events };
|
||||
return;
|
||||
}
|
||||
|
||||
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
|
||||
@@ -4612,7 +4846,6 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
|
||||
}
|
||||
}
|
||||
checkVictory(state, events);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
|
||||
@@ -4896,6 +5129,22 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
return null;
|
||||
};
|
||||
|
||||
// DISEASE (rev 7): "You're the carrier!" — a self-cast plague on fear's
|
||||
// pattern, no target and no counteraction window ("REFLECTIONs have no
|
||||
// effect against this"). Older games cast it at a victim through the
|
||||
// stack; their ledgers replay the legacy attack path below.
|
||||
if (inHand.cardId === "disease" && (state.config.deckRev ?? 1) >= 7) {
|
||||
consumeCast(state, caster, inHand, mods, false);
|
||||
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,
|
||||
}];
|
||||
attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
if (effect.kind === "attack") {
|
||||
const pre = attackPreconditions(state);
|
||||
if (pre) return err(pre);
|
||||
@@ -4907,7 +5156,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
|
||||
return err("you must be in the same square");
|
||||
}
|
||||
if (effect.requiresLos && !casterLos(state, caster, caster.position, creature.position)) {
|
||||
if (effect.requiresLos && !(mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, creature.position)
|
||||
: casterLos(state, caster, caster.position, creature.position))) {
|
||||
return err("no line of sight to the creature");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
@@ -4943,7 +5194,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (cmd.target?.kind === "cell" &&
|
||||
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
|
||||
const cell = cmd.target.cell;
|
||||
if (effect.requiresLos && !casterLos(state, caster, caster.position, cell)) {
|
||||
if (effect.requiresLos && !(mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, cell)
|
||||
: casterLos(state, caster, caster.position, cell))) {
|
||||
return err("no line of sight to the slime");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
@@ -4990,7 +5243,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
|
||||
return err("you must stand beside the wall");
|
||||
}
|
||||
if (effect.requiresLos && !losToEdge(view, caster.position, cell, side)) {
|
||||
if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) {
|
||||
return err("no line of sight to the wall");
|
||||
}
|
||||
const dmg =
|
||||
@@ -5062,7 +5315,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const preEvents: GameEvent[] = [];
|
||||
if (effect.requiresLos) {
|
||||
const sighted = mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, target.position, preEvents)
|
||||
? bentLos(state, caster, caster.position, target.position)
|
||||
: casterLos(state, caster, caster.position, target.position);
|
||||
if (!sighted) return err("no line of sight to the target");
|
||||
}
|
||||
@@ -5183,7 +5436,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}
|
||||
|
||||
// Neutral: validate on a preview clone before consuming any cards.
|
||||
const events: GameEvent[] = [{
|
||||
const events: GameEvent[] = [];
|
||||
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
|
||||
events.push({
|
||||
type: "spellCast",
|
||||
caster: caster.id,
|
||||
card: inHand,
|
||||
@@ -5193,7 +5448,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
from: caster.position,
|
||||
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
|
||||
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
|
||||
}];
|
||||
});
|
||||
const preview = clone(state);
|
||||
const problem = (effect as NeutralEffect).resolve(
|
||||
preview, [], activePlayer(preview), cmd, mods.magnitude,
|
||||
@@ -5677,7 +5932,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
? effect.baseDamage(stack.numberValue, stack.params)
|
||||
: stack.creatureId
|
||||
? (stack.params?.damage ?? 1) // a creature's blow
|
||||
: 1; // a punch
|
||||
: stack.tearTreasure
|
||||
? 0 // the tear wrestles; it does not bruise
|
||||
: (stack.params?.damage ?? 1); // a punch — or a cardless trap's bite
|
||||
// Webs burn: "Any fire damage done to player in webs causes two extra points."
|
||||
if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) {
|
||||
base += 2;
|
||||
@@ -5801,7 +6058,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
effect.onResolved({
|
||||
state, events,
|
||||
attacker: defender, defender: attacker,
|
||||
damageDealt: 0, fullyStopped: false, duration: 0,
|
||||
damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
|
||||
stack: { ...stack, params: { ...stack.params, cardId: chosen } },
|
||||
});
|
||||
}
|
||||
@@ -5818,9 +6075,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
return;
|
||||
}
|
||||
// The returned spell is a fresh attack on its own caster — who gets a
|
||||
// defender's counteraction window against it.
|
||||
// defender's counteraction window against it. Rev 11: permanent curses
|
||||
// ride the return too; older games let a reflected curse evaporate.
|
||||
const returnsCurse = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 11;
|
||||
if (!stack.trapped && !attackingCreature &&
|
||||
attacker.alive && (pipe.damage > 0 || pipe.duration > 0)) {
|
||||
attacker.alive && (pipe.damage > 0 || pipe.duration > 0 || returnsCurse)) {
|
||||
state.stack = {
|
||||
attackerId: defender.id,
|
||||
defenderId: attacker.id,
|
||||
@@ -5867,7 +6126,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
} else if (pipe.damage > 0) {
|
||||
const source = stack.reflectedBase
|
||||
? `${attackId} (reflected)`
|
||||
: attackId ??
|
||||
: attackId ?? stack.sourceName ??
|
||||
(attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`);
|
||||
applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind);
|
||||
damageDealt = pipe.damage;
|
||||
@@ -5907,6 +6166,15 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
// The tear's wrestling waits for the counters: only an unstopped grab,
|
||||
// still in arm's reach of a still-laden defender, gets its clutch roll.
|
||||
if (stack.tearTreasure && !pipe.fullyStopped && !pipe.redirected &&
|
||||
attacker.alive && defender.alive &&
|
||||
cellKey(attacker.position) === cellKey(defender.position) &&
|
||||
defender.carriedTreasureId != null) {
|
||||
executeTear(state, events, attacker, defender);
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
attacker: attacker.id,
|
||||
@@ -5926,6 +6194,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
defender,
|
||||
damageDealt,
|
||||
fullyStopped: pipe.fullyStopped,
|
||||
reversed: pipe.reversed,
|
||||
duration: pipe.duration,
|
||||
stack,
|
||||
});
|
||||
@@ -6058,11 +6327,25 @@ function applyDamage(
|
||||
soaks.push({ what: "bloodstone", amount: 1 });
|
||||
}
|
||||
// SOULSTONE: "Last three points ... can only be lost to physical damage."
|
||||
if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) {
|
||||
// Rev 8: the floor holds even from below — a bearer already at 3 or
|
||||
// less takes NOTHING from spells. Earlier revisions clamped only from
|
||||
// above (a bearer at 1 could be burned to death), and their ledgers
|
||||
// replay that flaw.
|
||||
if (damageKind === "spell" && displays(target, "soulstone")) {
|
||||
if ((state.config.deckRev ?? 1) >= 8) {
|
||||
const clamped = Math.max(0, Math.min(amount, target.life - 3));
|
||||
if (clamped === 0) {
|
||||
events.push({ type: "damageImmune", player: target.id, source, because: "soulstone" });
|
||||
return;
|
||||
}
|
||||
if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped });
|
||||
amount = clamped;
|
||||
} else if (target.life > 3) {
|
||||
const clamped = Math.min(amount, target.life - 3);
|
||||
if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped });
|
||||
amount = clamped;
|
||||
}
|
||||
}
|
||||
|
||||
target.life -= amount;
|
||||
events.push({
|
||||
@@ -6140,6 +6423,10 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
|
||||
const events: GameEvent[] = [
|
||||
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
|
||||
];
|
||||
// This grab may have lifted the owner's IDIOT: with every one of their
|
||||
// treasures now carried, there is nothing left to march to.
|
||||
const cursedOwner = state.players.find((q) => q.id === t.owner);
|
||||
if (cursedOwner) liftIdiotIfSatisfied(state, events, cursedOwner);
|
||||
// WARD: "you may play at that time (out of turn) this card on him" —
|
||||
// literally: the grab hangs while the owner decides.
|
||||
const owner = state.players.find((q) => q.id === t.owner);
|
||||
@@ -6312,11 +6599,18 @@ function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]):
|
||||
return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] };
|
||||
}
|
||||
|
||||
/** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws." */
|
||||
/** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws" —
|
||||
* or gains one, for a curse that was REVERSED at its casting. */
|
||||
function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void {
|
||||
const stacks = sustainedOn(state, p.id, "slow-death").length;
|
||||
if (stacks === 0 || cardsDrawn === 0 || !p.alive) return;
|
||||
for (let i = 0; i < cardsDrawn * stacks; i++) {
|
||||
const curses = sustainedOn(state, p.id, "slow-death");
|
||||
if (curses.length === 0 || cardsDrawn === 0 || !p.alive) return;
|
||||
const blessed = curses.filter((e) => e.data.reversed).length;
|
||||
if (blessed > 0) {
|
||||
const amount = cardsDrawn * blessed;
|
||||
p.life += amount;
|
||||
events.push({ type: "lifeGained", player: p.id, amount, source: "slow death (reversed)", lifeAfter: p.life });
|
||||
}
|
||||
for (let i = 0; i < cardsDrawn * (curses.length - blessed); i++) {
|
||||
if (!p.alive) break;
|
||||
applyDamage(state, events, p, 1, "slow death", null);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
@@ -223,6 +223,32 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sight with an AROUND THE CORNER attached: every square straightly sighted,
|
||||
* plus every square visible from one of those — the caster looks to a middle
|
||||
* cell and the spell turns there, mirroring the engine's bentLos.
|
||||
*/
|
||||
export function bentSightedCellsFor(view: GameView): Set<string> {
|
||||
const out = sightedCellsFor(view);
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return out;
|
||||
const mids = [...out].map((k) => {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
return { x, y };
|
||||
});
|
||||
const { board, blockers } = sightBasis(view);
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
if (out.has(key)) continue;
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const cell = { x, y };
|
||||
if (mids.some((mid) => sightBetween(board, mid, cell, blockers)) ||
|
||||
bentSightThroughGap(board, me.position, cell, blockers)) {
|
||||
out.add(key);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
||||
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
||||
// Held-open doors are open doorways to every eye. Beyond them, the
|
||||
@@ -296,7 +322,14 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
||||
*/
|
||||
export function stackSightTrace(
|
||||
view: GameView,
|
||||
): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null {
|
||||
): {
|
||||
from: Cell;
|
||||
to: Cell;
|
||||
trace: SightTrace;
|
||||
bend?: { mid: Cell; trace: SightTrace };
|
||||
/** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */
|
||||
pierced?: string;
|
||||
} | null {
|
||||
const stack = view.stack;
|
||||
if (!stack || stack.creatureId) return null;
|
||||
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
||||
@@ -306,6 +339,19 @@ export function stackSightTrace(
|
||||
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
||||
const trace = traceSightFor(view, a.position, d.position);
|
||||
if (trace) return { from: a.position, to: d.position, trace };
|
||||
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
|
||||
// plain line exists, find the single edge whose removal opens one and draw
|
||||
// the ray straight through it, marking the pierced wall for the table.
|
||||
if (a.displayed.some((c) => c.cardId === "visionstone")) {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
for (const [key, edge] of Object.entries(board.edges)) {
|
||||
if (edge === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
const t = traceSight({ ...board, edges }, a.position, d.position, blockers);
|
||||
if (t) return { from: a.position, to: d.position, trace: t, pierced: key };
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
@@ -337,12 +383,12 @@ const SUMMON_CARD_IDS = new Set([
|
||||
* aid — mirroring the engine's own validation from the viewer's knowledge.
|
||||
* Null = this card's eligibility is not modeled; light everything.
|
||||
*/
|
||||
export function eligibleCellsFor(view: GameView, cardId: string): Set<string> | null {
|
||||
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null {
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return null;
|
||||
const cells = Object.keys(view.board.cells);
|
||||
const key = (x: number, y: number) => `${x},${y}`;
|
||||
const sighted = sightedCellsFor(view);
|
||||
const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view);
|
||||
|
||||
if (CREATION_CARD_IDS.has(cardId)) {
|
||||
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
||||
|
||||
@@ -979,3 +979,86 @@ describe("the denial planner offers only castable blocks", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("interception, escape, and hazard sense", () => {
|
||||
function toBot(state: GameState): GameState {
|
||||
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("roots a carrier closing on home with LOCK IN PLACE", () => {
|
||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toBot(state);
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
// The foe stands beside the bot, laden, a short walk from banking.
|
||||
foe.position = { x: bot.position.x, y: bot.position.y };
|
||||
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
||||
gold.position = null;
|
||||
gold.carriedBy = "foe";
|
||||
foe.carriedTreasureId = gold.id;
|
||||
foe.home = { ...bot.position };
|
||||
bot.hand = [{ cardId: "lock-in-place", instanceId: "LK" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "LK", target: { kind: "player", playerId: "foe" } });
|
||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("a pressed carrier of any temperament turns to mist", () => {
|
||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toBot(state);
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
// Bot hauls the foe's gold mid-journey with the foe breathing down its neck.
|
||||
const gold = state.treasures.find((t) => t.owner === "foe" && t.position)!;
|
||||
gold.position = null;
|
||||
gold.carriedBy = "bot";
|
||||
bot.carriedTreasureId = gold.id;
|
||||
bot.position = { ...foe.home };
|
||||
foe.position = { ...bot.position };
|
||||
bot.hand = [
|
||||
{ cardId: "mist-body", instanceId: "MB" },
|
||||
{ cardId: "number-3", instanceId: "N3" },
|
||||
{ cardId: "number-5", instanceId: "N5" },
|
||||
];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "MB", numberInstanceIds: ["N5"] });
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.sustained.some((s) => s.cardId === "mist-body" && s.targetId === "bot")).toBe(true);
|
||||
});
|
||||
|
||||
it("detours around a thornbush jail rather than diving in", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
state = toBot(state);
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.hand = [];
|
||||
// Two exits from the bot's square: the short road east holds a
|
||||
// thornbush, the long road stays clean. A jail is not a shortcut.
|
||||
const sides = ["N", "S", "E", "W"] as const;
|
||||
const open = sides.filter((s) => {
|
||||
const n = { x: bot.position.x + (s === "E" ? 1 : s === "W" ? -1 : 0),
|
||||
y: bot.position.y + (s === "S" ? 1 : s === "N" ? -1 : 0) };
|
||||
return state.board.cells[cellKey(n)] !== undefined;
|
||||
});
|
||||
expect(open.length).toBeGreaterThanOrEqual(2);
|
||||
const bushSide = open[0]!;
|
||||
const freeSide = open[1]!;
|
||||
for (const s2 of sides) {
|
||||
state.edgeOverrides[edgeKey(bot.position, s2)] =
|
||||
s2 === bushSide || s2 === freeSide ? "open" : "wall";
|
||||
}
|
||||
const bushCell = { x: bot.position.x + (bushSide === "E" ? 1 : bushSide === "W" ? -1 : 0),
|
||||
y: bot.position.y + (bushSide === "S" ? 1 : bushSide === "N" ? -1 : 0) };
|
||||
state.squareContents[cellKey(bushCell)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
// Whatever goal it picks, the first stride must not be into the bush.
|
||||
expect(cmd).toMatchObject({ type: "move" });
|
||||
expect((cmd as { direction: string }).direction).not.toBe(bushSide);
|
||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { applyCommand, activePlayer, boardView, createGame, type GameState } fro
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
|
||||
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
import { newGame, must, drain, giveCard, toRound2, faceOff } from "./helpers";
|
||||
|
||||
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
||||
/** Advance past round 1 (both players just end their turns). */
|
||||
@@ -398,6 +398,7 @@ describe("the ward window and chaos shields", () => {
|
||||
expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id });
|
||||
// Spring it: the thief bleeds 3 and the ward is spent.
|
||||
state = must(state, owner.id, { type: "wardChoice", play: true });
|
||||
state = drain(state);
|
||||
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
});
|
||||
@@ -1196,3 +1197,158 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
|
||||
expect(traced?.bend?.mid).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tear is an attack (rev 6)", () => {
|
||||
function tearRig(deckRev?: number) {
|
||||
let { state } = createGame({
|
||||
playerIds: ["att", "def"], seed: 42, sets: ["basic", "expansion1"],
|
||||
...(deckRev != null ? { deckRev } : {}),
|
||||
});
|
||||
state = toRound2(state);
|
||||
while (activePlayer(state).id !== "att") {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
}
|
||||
const att = state.players.find((p) => p.id === "att")!;
|
||||
const def = state.players.find((p) => p.id === "def")!;
|
||||
def.position = { ...att.position };
|
||||
const gold = state.treasures.find((t) => t.owner === "att" && t.position)!;
|
||||
gold.position = null;
|
||||
gold.carriedBy = "def";
|
||||
def.carriedTreasureId = gold.id;
|
||||
state.sustained.push({
|
||||
id: "fx-t", cardId: "strength", casterId: "att", targetId: "att",
|
||||
remainingTurns: 3, data: {},
|
||||
});
|
||||
return { state, att, def, gold };
|
||||
}
|
||||
|
||||
it("opens a counteraction window; a TELEPORT escape keeps the chest", () => {
|
||||
let { state, gold } = tearRig();
|
||||
const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.stack?.tearTreasure).toBe(true);
|
||||
expect(state.stack?.waitingOn).toBe("def");
|
||||
const def = state.players.find((p) => p.id === "def")!;
|
||||
const view = boardView(state);
|
||||
const away = SIDES.map((s) => stepTarget(view, def.position, s))
|
||||
.find((t) => t.kind === "step")!;
|
||||
giveCard(state, "def", "teleport", "TP", 0);
|
||||
state = must(state, "def", { type: "counteract", instanceId: "teleport#TP", params: { cell: (away as { to: Cell }).to } });
|
||||
state = must(state, "att", { type: "pass" });
|
||||
if (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" });
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === "def")!.carriedTreasureId).toBe(gold.id);
|
||||
});
|
||||
|
||||
it("an uncountered tear wrestles after the window closes", () => {
|
||||
let { state, gold } = tearRig();
|
||||
const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
const res = applyCommand(state, "def", { type: "pass" });
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
state = res.state;
|
||||
const rolled = res.events.find((e) => e.type === "dieRolled");
|
||||
expect(rolled).toBeTruthy();
|
||||
const kept = rolled!.type === "dieRolled" && rolled!.roll === 1;
|
||||
const att = state.players.find((p) => p.id === "att")!;
|
||||
const def = state.players.find((p) => p.id === "def")!;
|
||||
if (kept) expect(def.carriedTreasureId).toBe(gold.id);
|
||||
else expect(att.carriedTreasureId).toBe(gold.id);
|
||||
});
|
||||
|
||||
it("rev 5 games keep the instant tear, no stack", () => {
|
||||
let { state } = tearRig(5);
|
||||
const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.stack).toBeNull();
|
||||
expect(r.events.some((e) => e.type === "treasureTorn" || e.type === "tearResisted")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a forced drop lands like any drop", () => {
|
||||
it("DROP OBJECT onto a home square reports the banking and checks victory", () => {
|
||||
let { state } = createGame({ playerIds: ["att", "def"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
while (activePlayer(state).id !== "att") {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
}
|
||||
const att = state.players.find((p) => p.id === "att")!;
|
||||
const def = state.players.find((p) => p.id === "def")!;
|
||||
// The defender stands on their OWN home carrying the attacker's gold —
|
||||
// the forced drop banks it for them, cruel as that is for the caster.
|
||||
def.position = { ...def.home };
|
||||
att.position = { ...def.home };
|
||||
const gold = state.treasures.find((t) => t.owner === "att" && t.position)!;
|
||||
gold.position = null;
|
||||
gold.carriedBy = "def";
|
||||
def.carriedTreasureId = gold.id;
|
||||
const dob = giveCard(state, "att", "drop-object", "D", 0);
|
||||
let r = applyCommand(state, "att", {
|
||||
type: "cast", instanceId: dob.instanceId,
|
||||
target: { kind: "player", playerId: "def" }, params: { cardId: "treasure" },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
r = applyCommand(r.state, "def", { type: "pass" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const drop = r.events.find((e) => e.type === "treasureDropped");
|
||||
expect(drop).toBeTruthy();
|
||||
if (drop?.type === "treasureDropped") expect(drop.onHomeOf).toBe("def");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disease makes the caster the carrier (rev 7)", () => {
|
||||
it("self-casts with no target; sharing a square bites both directions", () => {
|
||||
let { state } = createGame({ playerIds: ["carrier", "mark"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
while (activePlayer(state).id !== "carrier") {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
}
|
||||
const carrier = state.players.find((p) => p.id === "carrier")!;
|
||||
const mark = state.players.find((p) => p.id === "mark")!;
|
||||
const dz = giveCard(state, "carrier", "disease", "D", 0);
|
||||
giveCard(state, "carrier", "number-3", "N", 1);
|
||||
let r = applyCommand(state, "carrier", {
|
||||
type: "cast", instanceId: dz.instanceId, numberInstanceIds: ["number-3#N"],
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.sustained.some((s) => s.cardId === "disease" && s.targetId === "carrier")).toBe(true);
|
||||
|
||||
// The carrier walks INTO the mark's square: the mark takes 3.
|
||||
const view = boardView(state);
|
||||
const carrierNow = state.players.find((p) => p.id === "carrier")!;
|
||||
const markNow = state.players.find((p) => p.id === "mark")!;
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view, carrierNow.position, side);
|
||||
if (t.kind !== "step") continue;
|
||||
markNow.position = { ...t.to };
|
||||
const before = markNow.life;
|
||||
r = applyCommand(state, "carrier", { type: "move", direction: side });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.players.find((p) => p.id === "mark")!.life).toBe(before - 3);
|
||||
break;
|
||||
}
|
||||
state = must(state, "carrier", { type: "endTurn", draw: 0 });
|
||||
|
||||
// The mark walks INTO the carrier's square: the mark takes 3 again.
|
||||
const m2 = state.players.find((p) => p.id === "mark")!;
|
||||
const c2 = state.players.find((p) => p.id === "carrier")!;
|
||||
const view2 = boardView(state);
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view2, c2.position, side);
|
||||
if (t.kind !== "step") continue;
|
||||
m2.position = { ...t.to };
|
||||
const back = side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E";
|
||||
const before = m2.life;
|
||||
r = applyCommand(state, "mark", { type: "move", direction: back as "N" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.players.find((p) => p.id === "mark")!.life).toBe(before - 3);
|
||||
return;
|
||||
}
|
||||
throw new Error("no adjacent step for the return walk");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
||||
|
||||
/** Summon a creature next to its creator (round 2+, consumes the attack). */
|
||||
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
||||
@@ -151,24 +151,24 @@ describe("monsters", () => {
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state).id;
|
||||
const r = summon(state, me, "fire-imp");
|
||||
state = r.state;
|
||||
state = drain(r.state);
|
||||
const imp = state.creatures[0]!;
|
||||
|
||||
// Fireball cannot destroy it...
|
||||
state = must(state, me, { type: "endTurn", draw: 0 });
|
||||
state = drain(must(state, me, { type: "endTurn", draw: 0 }));
|
||||
const enemy = state.players.find((p) => p.id !== me)!;
|
||||
// (enemy may have been scorched at turn start if in LOS — note life)
|
||||
const enemyNow = state.players.find((p) => p.id !== me)!;
|
||||
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
|
||||
const fb = giveCard(state, enemy.id, "fireball", "F", 0);
|
||||
state = must(state, enemy.id, {
|
||||
state = drain(must(state, enemy.id, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id },
|
||||
});
|
||||
}));
|
||||
expect(state.creatures.length).toBe(1);
|
||||
|
||||
// ...but a waterbolt douses it instantly.
|
||||
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
||||
state = must(state, me, { type: "endTurn", draw: 0 });
|
||||
state = drain(must(state, enemy.id, { type: "endTurn", draw: 0 }));
|
||||
state = drain(must(state, me, { type: "endTurn", draw: 0 }));
|
||||
state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position };
|
||||
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
|
||||
state = must(state, enemy.id, {
|
||||
@@ -411,6 +411,33 @@ describe("big man", () => {
|
||||
throw new Error("setup: seed offered no walled pocket beside the home");
|
||||
});
|
||||
|
||||
it("a shrunk wizard slips between the giant's boots", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const mover = activePlayer(state);
|
||||
const giant = state.players.find((p) => p.id !== mover.id)!;
|
||||
state.sustained.push({
|
||||
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
|
||||
remainingTurns: 9, data: {},
|
||||
});
|
||||
const view = boardView(state);
|
||||
const side = SIDES.find((s) => stepTarget(view, mover.position, s).kind === "step");
|
||||
if (!side) throw new Error("setup: no open step beside the mover");
|
||||
const t = stepTarget(view, mover.position, side);
|
||||
if (t.kind !== "step") throw new Error("unreachable");
|
||||
giant.position = { ...t.to };
|
||||
// Full-size, the corridor is filled.
|
||||
expect(applyCommand(state, mover.id, { type: "move", direction: side }).ok).toBe(false);
|
||||
// Shrunk, the same step slips through.
|
||||
state.sustained.push({
|
||||
id: "fx-small", cardId: "shrink", casterId: mover.id, targetId: mover.id,
|
||||
remainingTurns: 1, data: {},
|
||||
});
|
||||
const r = applyCommand(state, mover.id, { type: "move", direction: side });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(cellKey(r.state.players.find((p) => p.id === mover.id)!.position)).toBe(cellKey(giant.position));
|
||||
});
|
||||
|
||||
it("monsters cannot enter the giant's square", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
@@ -787,3 +814,59 @@ describe("creatures and the dimensional warp", () => {
|
||||
if (!r.ok) expect(r.error).toContain("stone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the imp's fire is a spell (rev 8)", () => {
|
||||
function impRig() {
|
||||
let { state } = createGame({ playerIds: ["imp-owner", "mark"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
while (activePlayer(state).id !== "imp-owner") {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
it("scorch opens a counteraction window and FULL SHIELD stops it", () => {
|
||||
let state = impRig();
|
||||
const owner = state.players.find((p) => p.id === "imp-owner")!;
|
||||
const mark = state.players.find((p) => p.id === "mark")!;
|
||||
mark.position = { ...owner.position };
|
||||
const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 0);
|
||||
const spot = emptyNeighborCell(state, owner.position);
|
||||
let r = applyCommand(state, "imp-owner", {
|
||||
type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
|
||||
// The scorch rides the stack now: a counteraction window, spell-kind.
|
||||
expect(state.stack?.creatureId).toBeTruthy();
|
||||
expect(state.stack?.kind).toBe("spell");
|
||||
giveCard(state, "mark", "full-shield", "FS", 0);
|
||||
state = must(state, "mark", { type: "counteract", instanceId: "full-shield#FS" });
|
||||
state = drain(state);
|
||||
expect(state.players.find((p) => p.id === "mark")!.life).toBe(15);
|
||||
});
|
||||
|
||||
it("soulstone's floor holds from below: a bearer at 2 takes nothing from the flame", () => {
|
||||
let state = impRig();
|
||||
const owner = state.players.find((p) => p.id === "imp-owner")!;
|
||||
const mark = state.players.find((p) => p.id === "mark")!;
|
||||
mark.position = { ...owner.position };
|
||||
mark.life = 2;
|
||||
mark.displayed.push("SS");
|
||||
mark.hand[0] = { instanceId: "SS", cardId: "soulstone" };
|
||||
const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 1);
|
||||
const spot = emptyNeighborCell(state, owner.position);
|
||||
let r = applyCommand(state, "imp-owner", {
|
||||
type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
|
||||
expect(state.stack?.kind).toBe("spell");
|
||||
state = drain(state);
|
||||
const after = state.players.find((p) => p.id === "mark")!;
|
||||
expect(after.life).toBe(2);
|
||||
expect(after.alive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
||||
import { cellKey, edgeKey, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
|
||||
describe("expansion combat cards", () => {
|
||||
it("power attack burns life for extra damage", () => {
|
||||
@@ -73,8 +73,8 @@ describe("expansion combat cards", () => {
|
||||
.toEqual(aCards.filter((id) => id !== ms.instanceId));
|
||||
});
|
||||
|
||||
it("butt-head rams for the distance charged", () => {
|
||||
let { state } = newGame();
|
||||
it("butt-head rams for the distance charged (legacy rev 9 rules)", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 9 });
|
||||
state = toRound2(state);
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
@@ -116,6 +116,7 @@ describe("expansion combat cards", () => {
|
||||
state = must(state, me.id, { type: "pickUpTreasure" });
|
||||
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
|
||||
state = must(state, enemy.id, { type: "wardChoice", play: true });
|
||||
state = drain(state);
|
||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
});
|
||||
@@ -164,6 +165,34 @@ describe("expansion combat cards", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("idiot lifts the moment the victim's last floor treasure is carried off", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic"] });
|
||||
while (state.turn.round < 2) {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
}
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const third = state.players.find((p) => p.id !== attacker && p.id !== defender)!;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
||||
|
||||
// A third wizard hauls off one of the victim's chests: one destination
|
||||
// remains, so the march goes on.
|
||||
const [first, second] = state.treasures.filter((t) => t.owner === defender);
|
||||
const hauler = state.players.find((p) => p.id === third.id)!;
|
||||
first!.carriedBy = hauler.id;
|
||||
first!.position = null;
|
||||
hauler.carriedTreasureId = first!.id;
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
||||
|
||||
// The caster takes the last one off the floor — nothing left to march
|
||||
// to, and the curse lifts on the spot.
|
||||
const att = state.players.find((p) => p.id === attacker)!;
|
||||
att.position = { ...second!.position! };
|
||||
state = must(state, attacker, { type: "pickUpTreasure", treasureId: second!.id });
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
|
||||
});
|
||||
|
||||
it("idiot forbids item handling and punches but allows counteractions", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
@@ -417,7 +446,11 @@ describe("strength tears treasures from wizards' arms", () => {
|
||||
let torn = 0, kept = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const { state, attacker, defender, treasure } = grip(seed);
|
||||
const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
|
||||
let r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// Rev 6: the grab waits out its counteraction window first.
|
||||
expect(r.state.stack?.tearTreasure).toBe(true);
|
||||
r = applyCommand(r.state, defender, { type: "pass" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.turn.attackUsed).toBe(true);
|
||||
// FAQ: tearing is not picking up — the turn's actions continue.
|
||||
@@ -477,7 +510,10 @@ describe("strength tears treasures from wizards' arms", () => {
|
||||
state = must(state, "alice", {
|
||||
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
});
|
||||
const r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" });
|
||||
let r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// Rev 6: the grab waits out its counteraction window first.
|
||||
r = applyCommand(r.state, "bob", { type: "pass" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const after = r.state.treasures.find((t) => t.id === caras.id)!;
|
||||
if (after.carriedBy === "bob") continue; // Bob rolled his 1 — try again
|
||||
@@ -674,3 +710,36 @@ describe("go away routs around walls", () => {
|
||||
expect(after.lostTurns).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the goat charges on legs, not wings (rev 10)", () => {
|
||||
it("the charge walks real corridors: in reach it spends legs, beyond them it is refused", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
const bh = giveCard(state, attacker.id, "butt-head");
|
||||
// The engine is the oracle: every other square is either within the
|
||||
// turn's legal movement or beyond it, and this maze holds both kinds.
|
||||
let refusals = 0;
|
||||
let rams = 0;
|
||||
for (const key of Object.keys(boardView(state).cells)) {
|
||||
if (key === cellKey(attacker.position)) continue;
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
defender.position = { x, y };
|
||||
const r = applyCommand(state, attacker.id, {
|
||||
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
if (!r.ok) {
|
||||
if (/legs, not wings/.test(r.error)) refusals += 1;
|
||||
continue;
|
||||
}
|
||||
const st = drain(r.state);
|
||||
const a = st.players.find((p) => p.id === attacker.id)!;
|
||||
expect(a.position).toEqual(st.players.find((p) => p.id === defender.id)!.position);
|
||||
expect(st.turn.movementUsed).toBeGreaterThan(0);
|
||||
rams += 1;
|
||||
}
|
||||
expect(refusals).toBeGreaterThan(0);
|
||||
expect(rams).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,3 +378,80 @@ describe("a wave's force is spent as it travels", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("boobytrap decoys die their own deaths", () => {
|
||||
it("a stepped-on blank token vanishes alone; the real one still waits", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||
const view = boardView(state);
|
||||
const open: Cell[] = [];
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (state.squareContents[key]) continue;
|
||||
open.push({ x, y });
|
||||
if (open.length === 4) break;
|
||||
}
|
||||
const bt = giveCard(state, me.id, "boobytrap");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: bt.instanceId, params: { cells: open },
|
||||
});
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
// The enemy steps onto a BLANK (open[1] — the real trap is open[0]).
|
||||
const e = state.players.find((p) => p.id === enemy.id)!;
|
||||
const blank = open[1]!;
|
||||
for (const side of SIDES) {
|
||||
const from = { x: blank.x + (side === "E" ? -1 : side === "W" ? 1 : 0),
|
||||
y: blank.y + (side === "S" ? -1 : side === "N" ? 1 : 0) };
|
||||
if (!view.cells[cellKey(from)]) continue;
|
||||
const t = stepTarget(view, from, side);
|
||||
if (t.kind === "step" && cellKey(t.to) === cellKey(blank)) {
|
||||
e.position = from;
|
||||
const before = e.life;
|
||||
const r = applyCommand(state, enemy.id, { type: "move", direction: side });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(r.events.some((ev) => ev.type === "boobytrapBlank")).toBe(true);
|
||||
const after = state.players.find((p) => p.id === enemy.id)!;
|
||||
expect(after.life).toBe(before);
|
||||
const trap = state.boobytraps[0]!;
|
||||
expect(trap.cells.length).toBe(3);
|
||||
expect(trap.cells.some((c) => cellKey(c) === cellKey(blank))).toBe(false);
|
||||
expect(trap.realKey).toBe(cellKey(open[0]!));
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error("setup: no approach to the blank token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the boobytrap keeps its secret", () => {
|
||||
it("stored and broadcast cells are canonically ordered — position tells nothing", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
const open: Cell[] = [];
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (state.squareContents[key]) continue;
|
||||
open.push({ x, y });
|
||||
if (open.length === 4) break;
|
||||
}
|
||||
// Cast with the REAL trap deliberately last-sorting: reverse order.
|
||||
const reversed = [...open].reverse();
|
||||
const bt = giveCard(state, me.id, "boobytrap");
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: bt.instanceId, params: { cells: reversed },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const trap = r.state.boobytraps[0]!;
|
||||
const keys = trap.cells.map((c) => cellKey(c));
|
||||
expect([...keys].sort()).toEqual(keys); // canonical order, not casting order
|
||||
expect(trap.realKey).toBe(cellKey(reversed[0]!)); // the truth survives aside
|
||||
const placed = r.events.find((e) => e.type === "boobytrapPlaced");
|
||||
if (placed?.type === "boobytrapPlaced") {
|
||||
const evKeys = placed.cells.map((c) => cellKey(c));
|
||||
expect([...evKeys].sort()).toEqual(evKeys);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { giveCard } from "./helpers";
|
||||
import { drain, giveCard } from "./helpers";
|
||||
import { edgeKey } from "../src/board";
|
||||
import {
|
||||
applyCommand,
|
||||
@@ -290,7 +290,15 @@ describe("the Ward is played in the moment", () => {
|
||||
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
|
||||
const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// The bite rides the stack: a counteraction window opens for the thief.
|
||||
expect(r.state.stack).toBeTruthy();
|
||||
const bite = applyCommand(r.state, r.state.stack!.waitingOn, { type: "pass" });
|
||||
if (!bite.ok) throw new Error(bite.error);
|
||||
// The chronicle names the bite for what it is, not a punch.
|
||||
expect(bite.events.some(
|
||||
(e) => e.type === "damaged" && e.source === "warded treasure",
|
||||
)).toBe(true);
|
||||
state = drain(bite.state);
|
||||
expect(state.wardPending).toBeNull();
|
||||
expect(state.players.find((p) => p.id === "thief")!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
|
||||
@@ -29,6 +29,14 @@ export function must(state: GameState, player: PlayerId, command: Command): Game
|
||||
return result.state;
|
||||
}
|
||||
|
||||
/** Wave every pending counteraction window through unanswered. */
|
||||
export function drain(state: GameState): GameState {
|
||||
while (state.stack) {
|
||||
state = must(state, state.stack.waitingOn, { type: "pass" });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
||||
const p = state.players.find((p) => p.id === playerId)!;
|
||||
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
||||
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
import { newGame, newExpansionGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
|
||||
describe("around the corner", () => {
|
||||
it("bends line of sight past a wall that blocks a straight cast", () => {
|
||||
@@ -44,6 +44,130 @@ describe("around the corner", () => {
|
||||
state = must(state, defender.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10);
|
||||
});
|
||||
|
||||
it("hairpins 180 degrees around a wall's end — the pivot sits in the gap", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
const view0 = boardView(state);
|
||||
// Carve a 2x2 block: attacker at A, defender at C directly north behind a
|
||||
// wall that ends beside the B/D gap. C is sealed on every other side, so
|
||||
// no cell-centered bend reaches it — only the pivot in the gap itself.
|
||||
// +---+---+ C = target D = gap's far cell
|
||||
// # C D # A = caster B = gap's near cell
|
||||
// +###+ +
|
||||
// A B
|
||||
let A: Cell | null = null;
|
||||
for (const k of Object.keys(view0.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
if (view0.cells[`${x + 1},${y}`] && view0.cells[`${x},${y - 1}`] && view0.cells[`${x + 1},${y - 1}`]) {
|
||||
A = { x, y };
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(A).not.toBeNull();
|
||||
const B = { x: A!.x + 1, y: A!.y };
|
||||
const C = { x: A!.x, y: A!.y - 1 };
|
||||
const D = { x: A!.x + 1, y: A!.y - 1 };
|
||||
state.edgeOverrides[edgeKey(A!, "N")] = "wall"; // the wall to double back around
|
||||
state.edgeOverrides[edgeKey(A!, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey(B, "N")] = "open"; // the gap past the wall's end
|
||||
state.edgeOverrides[edgeKey(C, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey(C, "N")] = "wall";
|
||||
state.edgeOverrides[edgeKey(C, "W")] = "wall";
|
||||
state.edgeOverrides[edgeKey(D, "E")] = "wall";
|
||||
attacker.position = { ...A! };
|
||||
defender.position = { ...C };
|
||||
|
||||
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
|
||||
giveCard(state, attacker.id, "around-the-corner", "ATC", 1);
|
||||
const straight = applyCommand(state, attacker.id, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
expect(straight.ok).toBe(false);
|
||||
|
||||
const lifeBefore = defender.life;
|
||||
state = must(state, attacker.id, {
|
||||
type: "cast", instanceId: fb.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||
target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
state = must(state, defender.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBeLessThan(lifeBefore);
|
||||
});
|
||||
|
||||
// A cell out of straight sight but reachable with one bend, clear enough
|
||||
// to create on: no contents, home, wizard, treasure, object, or warp.
|
||||
function hiddenEmptyCell(state: ReturnType<typeof newGame>["state"]): Cell {
|
||||
const caster = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const cell = { x, y };
|
||||
if (gameLos(state, caster.position, cell, caster.id)) continue;
|
||||
if (state.squareContents[key]) continue;
|
||||
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) continue;
|
||||
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) continue;
|
||||
if ((state.groundObjects[key] ?? []).length > 0) continue;
|
||||
for (const midKey of Object.keys(view.cells)) {
|
||||
const [mx, my] = midKey.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if (gameLos(state, caster.position, mid, caster.id) && gameLos(state, mid, cell, caster.id)) {
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("no hidden-but-bendable cell on this board");
|
||||
}
|
||||
|
||||
it("bends a neutral spell too — SAFE locks up a treasure around the corner", () => {
|
||||
let { state } = newExpansionGame();
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const hidden = hiddenEmptyCell(state);
|
||||
const gold = state.treasures.find((t) => t.position)!;
|
||||
gold.position = { ...hidden };
|
||||
|
||||
const safe = giveCard(state, caster.id, "safe", "S", 0);
|
||||
giveCard(state, caster.id, "around-the-corner", "ATC", 1);
|
||||
const straight = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: hidden },
|
||||
});
|
||||
expect(straight.ok).toBe(false);
|
||||
if (!straight.ok) expect(straight.error).toBe("no line of sight");
|
||||
|
||||
const bent = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: safe.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||
target: { kind: "cell", cell: hidden },
|
||||
});
|
||||
if (!bent.ok) throw new Error(`bent SAFE refused: ${bent.error}`);
|
||||
expect(bent.state.squareContents[cellKey(hidden)]?.kind).toBe("safe");
|
||||
expect(bent.events.some((e) => e.type === "castAroundCorner")).toBe(true);
|
||||
// Both cards spent: the bend is not free.
|
||||
const after = bent.state.players.find((p) => p.id === caster.id)!;
|
||||
expect(after.hand.some((c) => c?.cardId === "around-the-corner")).toBe(false);
|
||||
});
|
||||
|
||||
it("bends a creation — THORNBUSH grows on a square out of straight sight", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const hidden = hiddenEmptyCell(state);
|
||||
|
||||
const bush = giveCard(state, caster.id, "thornbush", "TB", 0);
|
||||
giveCard(state, caster.id, "around-the-corner", "ATC", 1);
|
||||
const straight = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell: hidden },
|
||||
});
|
||||
expect(straight.ok).toBe(false);
|
||||
|
||||
state = must(state, caster.id, {
|
||||
type: "cast", instanceId: bush.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||
target: { kind: "cell", cell: hidden },
|
||||
});
|
||||
expect(state.squareContents[cellKey(hidden)]?.kind).toBe("thornbush");
|
||||
});
|
||||
});
|
||||
|
||||
describe("blind", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
import { createGame } from "../src/game";
|
||||
import { newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
|
||||
/** Display a stone for a player during their turn. */
|
||||
function displayStone(state: GameState, playerId: PlayerId, stoneId: string, slot = 0): GameState {
|
||||
@@ -147,6 +148,95 @@ describe("slow death", () => {
|
||||
expect(sustainedOn(state, defender, "slow-death").length).toBe(1);
|
||||
});
|
||||
|
||||
it("a reverse at the casting turns the curse: a point gained per draw (rev 11)", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sd = giveCard(state, attacker, "slow-death");
|
||||
giveCard(state, defender, "reverse", "RV", 0);
|
||||
const r = applyCommand(state, attacker, {
|
||||
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
|
||||
state = drain(state);
|
||||
expect(sustainedOn(state, defender, "slow-death").length).toBe(1);
|
||||
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const toDiscard = d.hand.slice(0, 2).map((c) => c.instanceId);
|
||||
state = must(state, defender, { type: "discard", instanceIds: toDiscard });
|
||||
state = must(state, defender, { type: "endTurn", draw: 2 });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(17);
|
||||
});
|
||||
|
||||
it("a reversed walking dead heals half a point per space walked (rev 11)", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const wd = giveCard(state, attacker, "walking-dead");
|
||||
giveCard(state, defender, "reverse", "RV", 0);
|
||||
const r = applyCommand(state, attacker, {
|
||||
type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
|
||||
state = drain(state);
|
||||
expect(sustainedOn(state, defender, "walking-dead").length).toBe(1);
|
||||
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
// Two spaces walked: one point gained, not bled.
|
||||
let steps = 0;
|
||||
for (const dir of ["N", "S", "E", "W", "N", "S", "E", "W"] as const) {
|
||||
if (steps >= 2) break;
|
||||
const mv = applyCommand(state, defender, { type: "move", direction: dir });
|
||||
if (mv.ok) { state = mv.state; steps++; }
|
||||
}
|
||||
expect(steps).toBe(2);
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(16);
|
||||
});
|
||||
|
||||
it("a full reflection returns the curse onto its caster (rev 11)", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sd = giveCard(state, attacker, "slow-death");
|
||||
giveCard(state, defender, "full-reflection", "FR", 0);
|
||||
const r = applyCommand(state, attacker, {
|
||||
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = must(r.state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = drain(state);
|
||||
expect(sustainedOn(state, defender, "slow-death").length).toBe(0);
|
||||
expect(sustainedOn(state, attacker, "slow-death").length).toBe(1);
|
||||
|
||||
// The caster now bleeds for their own draws.
|
||||
state = must(state, attacker, { type: "endTurn", draw: 1 });
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(14);
|
||||
});
|
||||
|
||||
it("older decks attach the biting curse through a reverse (rev ≤10)", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sd = giveCard(state, attacker, "slow-death");
|
||||
giveCard(state, defender, "reverse", "RV", 0);
|
||||
const r = applyCommand(state, attacker, {
|
||||
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
|
||||
state = drain(state);
|
||||
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const toDiscard = d.hand.slice(0, 2).map((c) => c.instanceId);
|
||||
state = must(state, defender, { type: "discard", instanceIds: toDiscard });
|
||||
state = must(state, defender, { type: "endTurn", draw: 2 });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(13);
|
||||
});
|
||||
|
||||
it("bloodstone cancels slow death's per-draw point", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
|
||||
@@ -147,19 +147,26 @@ function shareData(id: string): ShareData | null {
|
||||
const escapeHtml = (t: string) =>
|
||||
t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
/** index.html with this share's OpenGraph card folded into its head —
|
||||
* crawlers never run the app, so the unfurl must arrive pre-baked. */
|
||||
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
||||
// Host and proto arrive from request headers — attacker-writable text
|
||||
// that must never reach an HTML attribute raw.
|
||||
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
||||
const host = escapeHtml(rawHost);
|
||||
// The stock page carries its own generic card; strip it, or crawlers
|
||||
// (which take the FIRST tag they meet) never see this turn's.
|
||||
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
|
||||
/** index.html with a bespoke OpenGraph card folded into its head —
|
||||
* crawlers never run the app, so the unfurl must arrive pre-baked. The
|
||||
* stock page carries its own generic card; strip it, or crawlers (which
|
||||
* take the FIRST tag they meet) never see this page's. */
|
||||
function ogPage(metas: string[]): string {
|
||||
return readFileSync(join(staticRoot!, "index.html"), "utf8")
|
||||
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
||||
.replace(/<title>[^<]*<\/title>\s*/, "");
|
||||
const base = `${proto}://${host}`;
|
||||
.replace(/<title>[^<]*<\/title>\s*/, "")
|
||||
.replace("</head>", ` ${metas.join("\n ")}\n </head>`);
|
||||
}
|
||||
|
||||
/** Host and proto arrive from request headers — attacker-writable text
|
||||
* that must never reach an HTML attribute raw. */
|
||||
function safeBase(rawHost: string, rawProto: string): string {
|
||||
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
||||
return `${proto}://${escapeHtml(rawHost)}`;
|
||||
}
|
||||
|
||||
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
||||
const base = safeBase(rawHost, rawProto);
|
||||
const title = data.whole
|
||||
? "The whole tale — a game of Wiz-War, replayed"
|
||||
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
||||
@@ -179,8 +186,36 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
|
||||
`<meta property="og:image:height" content="630"/>`,
|
||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
|
||||
].join("\n ");
|
||||
return html.replace("</head>", ` ${metas}\n </head>`);
|
||||
];
|
||||
return ogPage(metas);
|
||||
}
|
||||
|
||||
/** The recruiting card for a /join/<code> link: an invitation while the
|
||||
* room waits to start, a summons to the gallery once it has. */
|
||||
function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
|
||||
const base = safeBase(rawHost, rawProto);
|
||||
const seats = room.players.length;
|
||||
const wizards = `${seats} wizard${seats === 1 ? "" : "s"}`;
|
||||
const title = `You're summoned — Wiz-War room ${room.id}`;
|
||||
const desc = room.state?.phase === "finished"
|
||||
? `The tale is told — ${wizards} fought in this labyrinth. Follow the link to see how it ended.`
|
||||
: room.state
|
||||
? `The duel is underway, ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
|
||||
: `${wizards} at the table, waiting to flip the boards. Follow the link, pick a name, and take a seat.`;
|
||||
const metas = [
|
||||
`<title>${title}</title>`,
|
||||
`<meta property="og:type" content="website"/>`,
|
||||
`<meta property="og:site_name" content="Wiz-War"/>`,
|
||||
`<meta property="og:title" content="${title}"/>`,
|
||||
`<meta property="og:description" content="${desc}"/>`,
|
||||
`<meta property="og:url" content="${base}/join/${room.id}"/>`,
|
||||
`<meta property="og:image" content="${base}/og.png"/>`,
|
||||
`<meta property="og:image:width" content="1200"/>`,
|
||||
`<meta property="og:image:height" content="630"/>`,
|
||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||
`<meta name="twitter:image" content="${base}/og.png"/>`,
|
||||
];
|
||||
return ogPage(metas);
|
||||
}
|
||||
const httpServer = createServer((req, res) => {
|
||||
try {
|
||||
@@ -233,6 +268,19 @@ const httpServer = createServer((req, res) => {
|
||||
res.end(shareHtml(watch[1]!, data, hostname, proto));
|
||||
return;
|
||||
}
|
||||
// Room invitations: a living room gets its recruiting card; a dead
|
||||
// code falls through to the app, which reports it in the lobby.
|
||||
const invite = url.match(/^\/join\/([A-Za-z0-9]{4})$/);
|
||||
if (invite) {
|
||||
const room = getRoom(invite[1]!);
|
||||
if (room) {
|
||||
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
|
||||
const hostname = String(req.headers.host ?? `localhost:${port}`);
|
||||
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
|
||||
res.end(inviteHtml(room, hostname, proto));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
|
||||
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
|
||||
res.writeHead(403).end();
|
||||
@@ -475,6 +523,12 @@ wss.on("connection", (socket) => {
|
||||
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
// Liveness probe: the reply is the point. A restart can leave
|
||||
// browsers holding half-dead sockets that never fire onclose;
|
||||
// silence answered by silence is how the client finds out.
|
||||
send(socket, { type: "pong" });
|
||||
break;
|
||||
case "create": {
|
||||
const name = cleanName(msg.name);
|
||||
if (!name) return send(socket, { type: "error", message: "name required" });
|
||||
|
||||
+237
-50
@@ -14,7 +14,7 @@
|
||||
import { prefs, savePrefs } from "./prefs.svelte";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import { local } from "./local.svelte";
|
||||
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
|
||||
|
||||
net.connect();
|
||||
@@ -23,6 +23,33 @@
|
||||
let name = $state(prefs.wizardName);
|
||||
let joinCode = $state("");
|
||||
let claimPhrase = $state("");
|
||||
/** A /join/<CODE> recruiting link. The visitor lands looking at the
|
||||
* room: a held seat walks them back to it, a waiting room offers a
|
||||
* seat or the gallery, a started game is watched from the gallery. */
|
||||
const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
|
||||
if (inviteCode) joinCode = inviteCode;
|
||||
let inviteFollowed = false;
|
||||
$effect(() => {
|
||||
if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
|
||||
inviteFollowed = true;
|
||||
const seat = net.seats.find((s) => s.roomId === inviteCode);
|
||||
if (seat) net.resume(seat);
|
||||
else net.watch(inviteCode);
|
||||
});
|
||||
/** The address bar holds the table's invite link while you sit at one,
|
||||
* so recruiting is a copy away. */
|
||||
$effect(() => {
|
||||
const path = net.roomId ? `/join/${net.roomId}` : "/";
|
||||
if (location.pathname !== path) history.replaceState(null, "", path + location.search);
|
||||
});
|
||||
const inviteLink = $derived(net.roomId ? `${location.origin}/join/${net.roomId}` : "");
|
||||
let inviteCopied = $state(false);
|
||||
function copyInvite() {
|
||||
navigator.clipboard?.writeText(inviteLink).then(() => {
|
||||
inviteCopied = true;
|
||||
setTimeout(() => (inviteCopied = false), 1600);
|
||||
});
|
||||
}
|
||||
let showHelp = $state(false);
|
||||
/** The finished game whose victory fanfare has been dismissed. */
|
||||
let victorySeen = $state(false);
|
||||
@@ -73,6 +100,45 @@
|
||||
if (!prefs.cautions || reasons.length === 0) go();
|
||||
else caution = { reasons, go };
|
||||
}
|
||||
/** Board zoom: 1 = fitted to the viewport; higher magnifies for crowded
|
||||
* squares, with the viewport scrolling. The svg's width is always set
|
||||
* explicitly (fit × zoom) from measurement — auto-sizing left stale
|
||||
* dimensions behind when the first-person pane closed. */
|
||||
let boardViewportEl = $state<HTMLDivElement | null>(null);
|
||||
let boardZoom = $state(1);
|
||||
function zoomBoard(dir: number) {
|
||||
const el = boardViewportEl;
|
||||
const prev = boardZoom;
|
||||
const next = dir > 0 ? prev * 1.4 : prev / 1.4;
|
||||
boardZoom = next < 1.05 ? 1 : Math.min(4, Math.round(next * 100) / 100);
|
||||
if (el && boardZoom !== prev) {
|
||||
const f = boardZoom / prev;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollLeft = (el.scrollLeft + el.clientWidth / 2) * f - el.clientWidth / 2;
|
||||
el.scrollTop = (el.scrollTop + el.clientHeight / 2) * f - el.clientHeight / 2;
|
||||
});
|
||||
}
|
||||
}
|
||||
function fitBoard(el: HTMLDivElement) {
|
||||
const svg = el.querySelector("svg.board") as SVGSVGElement | null;
|
||||
const vb = svg?.viewBox.baseVal;
|
||||
if (!vb || !vb.width || !vb.height) return;
|
||||
const fitW = Math.min(el.clientWidth, (el.clientHeight * vb.width) / vb.height);
|
||||
el.style.setProperty("--fit-w", `${Math.max(0, fitW)}px`);
|
||||
}
|
||||
$effect(() => {
|
||||
const el = boardViewportEl;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => fitBoard(el));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
$effect(() => {
|
||||
void view; // the board's viewBox grows with ghost slots and relocations
|
||||
const el = boardViewportEl;
|
||||
if (el) requestAnimationFrame(() => fitBoard(el));
|
||||
});
|
||||
|
||||
/** A move with its regret checks: stepping off your home still laden,
|
||||
* or stepping onto ground that bites. */
|
||||
function tryMove(direction: Side, over?: boolean) {
|
||||
@@ -423,6 +489,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Attachment plumbing for a cast command: the chosen mods and the
|
||||
* attached NUMBER ride along (an explicit numberInstanceIds wins). */
|
||||
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
|
||||
applyMods(cmd);
|
||||
if (attachedNumber && !cmd.numberInstanceIds) {
|
||||
cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/** Suggest-as-you-type pool for the card-naming field, tuned per card. */
|
||||
const nameSuggestions = $derived.by(() => {
|
||||
if (!selectedCard) return [];
|
||||
@@ -542,6 +618,12 @@
|
||||
"shieldstone", "soulstone", "speedstone", "visionstone",
|
||||
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
|
||||
]);
|
||||
/** Cards cast on yourself via the Cast button. DISEASE joined at rev 7
|
||||
* ("You're the carrier!"); older games still aim it at a victim. */
|
||||
function confirmCastable(cardId: string): boolean {
|
||||
return CONFIRM_CAST.has(cardId) ||
|
||||
(cardId === "disease" && (view?.deckRev ?? 1) >= 7);
|
||||
}
|
||||
|
||||
function playNumberForMovement() {
|
||||
if (!selectedCard || !view) return;
|
||||
@@ -622,11 +704,11 @@
|
||||
if (!yourMoment) { peekAt(cell); return; }
|
||||
if (pendingCellFor && selectedCard) {
|
||||
// Stage 2 of teleport-opponent: destination chosen.
|
||||
dispatch({
|
||||
dispatch(withMods({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "player", playerId: pendingCellFor },
|
||||
params: { cell },
|
||||
});
|
||||
}));
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
@@ -653,17 +735,17 @@
|
||||
? trapCells.filter((_, i) => i !== already)
|
||||
: [...trapCells, cell];
|
||||
if (trapCells.length === 4) {
|
||||
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
||||
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } }));
|
||||
clearSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
||||
if (!tradeFrom) { tradeFrom = cell; return; }
|
||||
dispatch({
|
||||
dispatch(withMods({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
||||
});
|
||||
}));
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
@@ -676,11 +758,10 @@
|
||||
return;
|
||||
}
|
||||
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
||||
dispatch({
|
||||
dispatch(withMods({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "cell", cell },
|
||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||||
});
|
||||
}));
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
@@ -858,14 +939,13 @@
|
||||
if (!selectedCard || !edgeSelectMode) return;
|
||||
// The attached number rides along for every edge cast: wand charges,
|
||||
// wall-of-fire durations, wall attacks alike.
|
||||
dispatch({
|
||||
dispatch(withMods({
|
||||
type: "cast",
|
||||
instanceId: selectedCard.instanceId,
|
||||
target: { kind: "edge", cell, side },
|
||||
...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key")
|
||||
? { params: { hold: true } } : {}),
|
||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||||
});
|
||||
}));
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
@@ -881,12 +961,11 @@
|
||||
return;
|
||||
}
|
||||
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
|
||||
dispatch({
|
||||
dispatch(withMods({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "creature", creatureId },
|
||||
...(selectedCard.cardId === "mega-monster" ? { params: { boost: megaBoost } } : {}),
|
||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||||
});
|
||||
}));
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
@@ -942,13 +1021,12 @@
|
||||
swapMeetTarget = playerId;
|
||||
return; // next: pick your item, then theirs
|
||||
}
|
||||
const cmd: Parameters<typeof net.command>[0] = {
|
||||
const cmd: Parameters<typeof net.command>[0] & { type: "cast" } = {
|
||||
type: "cast",
|
||||
instanceId: selectedCard.instanceId,
|
||||
target: { kind: "player", playerId },
|
||||
};
|
||||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||||
applyMods(cmd);
|
||||
withMods(cmd);
|
||||
if (selectedCard.cardId === "waterbolt") {
|
||||
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
|
||||
}
|
||||
@@ -1006,6 +1084,13 @@
|
||||
here !== cellKey(me.home)) {
|
||||
reasons.push(`a treasure lies at your feet, un-grabbed`);
|
||||
}
|
||||
// Auto-grab about to pocket your OWN chest: carrying it does not
|
||||
// score and bares it to capture, so that walk-off asks first.
|
||||
if (autoWillGrab && !me.carriedTreasureId && !view.turn.actionsEnded &&
|
||||
!weakened && !view.gluedCells[here] && !safed && here !== cellKey(me.home) &&
|
||||
treasuresHere[0]!.owner === view.you) {
|
||||
reasons.push("your OWN treasure lies underfoot — ending the turn grabs it and carries it along");
|
||||
}
|
||||
// The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves.
|
||||
const drawRoom = Math.min(2, room);
|
||||
if (drawCount < drawRoom) {
|
||||
@@ -1172,8 +1257,9 @@
|
||||
if (!view || !selectedDef || !yourMoment) return null;
|
||||
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
||||
if (!me) return null;
|
||||
const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
|
||||
if (CELL_CARDS.has(selectedCard!.cardId)) {
|
||||
return eligibleCellsFor(view, selectedCard!.cardId);
|
||||
return eligibleCellsFor(view, selectedCard!.cardId, bent);
|
||||
}
|
||||
if (selectedDef.adjacent === true) {
|
||||
const { x, y } = me.position;
|
||||
@@ -1184,19 +1270,7 @@
|
||||
);
|
||||
}
|
||||
if (selectedDef.los !== true) return null;
|
||||
const sighted = sightedCellsFor(view);
|
||||
if (attachedMods.some((m) => m.cardId === "around-the-corner")) {
|
||||
const widened = new Set(sighted);
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
if (widened.has(key)) continue;
|
||||
const [cx, cy] = key.split(",").map(Number);
|
||||
if ([[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => sighted.has(`${cx! + dx!},${cy! + dy!}`))) {
|
||||
widened.add(key);
|
||||
}
|
||||
}
|
||||
return widened;
|
||||
}
|
||||
return sighted;
|
||||
return bent ? bentSightedCellsFor(view) : sightedCellsFor(view);
|
||||
});
|
||||
|
||||
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
|
||||
@@ -1423,6 +1497,11 @@
|
||||
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
|
||||
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
|
||||
</label>
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.handLeft}
|
||||
onchange={(e) => setPref("handLeft", e.currentTarget.checked)} />
|
||||
<span>Hand at the left: your cards stand in a column beside the board (wide screens)</span>
|
||||
</label>
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.cautions}
|
||||
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
||||
@@ -1826,7 +1905,11 @@
|
||||
<section class="boxlid">
|
||||
<div class="boxlid-inner">
|
||||
<div class="boxlid-title small">Room {net.roomId}</div>
|
||||
<div class="boxlid-tag">Share the code. Two to six wizards enter the maze.</div>
|
||||
<div class="boxlid-tag">Share the code — or the link. Two to six wizards enter the maze.</div>
|
||||
<div class="invite-row">
|
||||
<code class="invite-link">{inviteLink}</code>
|
||||
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy link"}</button>
|
||||
</div>
|
||||
<ul class="roster">
|
||||
{#each net.players as p (p)}
|
||||
{@const chosen = net.roomColors[p]}
|
||||
@@ -1846,6 +1929,15 @@
|
||||
</ul>
|
||||
{#if net.spectating}
|
||||
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
|
||||
{#if net.players.length < 6}
|
||||
<div class="gallery-join">
|
||||
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
|
||||
<button class="stamp" disabled={!name.trim()}
|
||||
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
|
||||
…or take a seat
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="standee-row" role="group" aria-label="choose your wizard">
|
||||
{#each [0, 1, 2, 3, 4, 5] as c (c)}
|
||||
@@ -1899,7 +1991,7 @@
|
||||
</div>
|
||||
</section>
|
||||
{:else if view}
|
||||
<div class="game">
|
||||
<div class="game" class:hand-left={prefs.handLeft}>
|
||||
<section class="board-zone">
|
||||
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
||||
{#if prefs.liveFp && view.you && !net.spectating}
|
||||
@@ -1914,8 +2006,11 @@
|
||||
onbody={(id) => (fpvBody = id)}
|
||||
onWarpStep={(creatureId) => dispatch(creatureId
|
||||
? { type: "creatureWarpStep", creatureId }
|
||||
: { type: "warpStep" })} />
|
||||
: { type: "warpStep" })}
|
||||
edgeSelect={edgeSelectMode && yourMoment} />
|
||||
{/if}
|
||||
<div class="board-frame">
|
||||
<div class="board-viewport" bind:this={boardViewportEl} style:--zoom={boardZoom}>
|
||||
<Board
|
||||
{view}
|
||||
edgeSelectMode={edgeSelectMode && yourMoment}
|
||||
@@ -1940,6 +2035,12 @@
|
||||
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
||||
/>
|
||||
</div>
|
||||
<div class="zoom-controls" role="group" aria-label="board zoom">
|
||||
<button class="zoom-btn" title="zoom in" onclick={() => zoomBoard(1)} disabled={boardZoom >= 4}>+</button>
|
||||
<button class="zoom-btn" title="zoom out" onclick={() => zoomBoard(-1)} disabled={boardZoom <= 1}>−</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="paper-rail">
|
||||
@@ -2153,7 +2254,6 @@
|
||||
</form>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="table-edge">
|
||||
{#if ambushVia}
|
||||
@@ -2199,7 +2299,8 @@
|
||||
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}`
|
||||
: view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId
|
||||
? `an illusionary ${cardDef(view.stack.params.cardId).name}`
|
||||
: view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} —
|
||||
: view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name
|
||||
: view.stack.tearTreasure ? "a grab at your treasure" : "a punch"} —
|
||||
tap a counteraction card, or
|
||||
{:else}
|
||||
{view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
|
||||
@@ -2322,7 +2423,7 @@
|
||||
<label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label>
|
||||
<button class="stamp tiny" onclick={castPowerRun}>Run!</button>
|
||||
{/if}
|
||||
{#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)}
|
||||
{#if selectedCard && confirmCastable(selectedCard.cardId)}
|
||||
<button class="stamp tiny" onclick={castSelfWithNumber}>
|
||||
Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
|
||||
</button>
|
||||
@@ -2461,6 +2562,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2713,6 +2815,31 @@
|
||||
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
|
||||
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
|
||||
.waiting { color: #6b5a41; font-style: italic; }
|
||||
.invite-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.invite-link {
|
||||
font-size: 0.8rem;
|
||||
color: #6b5a41;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.45rem;
|
||||
user-select: all;
|
||||
}
|
||||
.gallery-join {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.gallery-join input {
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.transfer-slip {
|
||||
max-width: 30rem;
|
||||
@@ -2914,14 +3041,46 @@
|
||||
min-height: 0;
|
||||
}
|
||||
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
|
||||
/* Hand at the left: the cards stand in their own column beside the
|
||||
* board while the hint strips and action buttons stay beneath it —
|
||||
* the strip dissolves into the grid so its children place themselves.
|
||||
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
|
||||
@media (min-width: 901px) {
|
||||
.game.hand-left {
|
||||
grid-template-columns: minmax(17.4rem, 18.2rem) minmax(340px, 1fr) minmax(250px, 330px);
|
||||
}
|
||||
.game.hand-left .table-edge { display: contents; }
|
||||
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
|
||||
.game.hand-left .table-edge > .hand {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 6;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 8.2rem);
|
||||
justify-content: center;
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
max-height: calc(100dvh - 5rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
padding: 0.3rem 0.3rem 0.9rem;
|
||||
}
|
||||
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
|
||||
.game.hand-left .paper-rail { grid-column: 3; grid-row: 1; }
|
||||
/* With the hand out of the bottom strip, the map claims the height. */
|
||||
.game.hand-left .board-viewport { height: calc(100dvh - 11rem); }
|
||||
.game.hand-left .fpv-primary .board-viewport { height: 30dvh; }
|
||||
}
|
||||
/* The first-person pane wears exactly the board's width, right above
|
||||
* it: the stack shrink-wraps to the board, and width:0 + min-width
|
||||
* keeps the pane out of the intrinsic sizing so the BOARD alone
|
||||
* decides how wide the column is. */
|
||||
.table-stack {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -2930,19 +3089,46 @@
|
||||
/* FPV play: the 3D view is the primary window into the game; the
|
||||
* top-down board shrinks to a keymap beneath it. */
|
||||
.table-stack.fpv-primary {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
}
|
||||
.board-zone .table-stack.fpv-primary :global(svg.board) {
|
||||
max-height: 30dvh;
|
||||
align-self: center;
|
||||
.board-frame { position: relative; min-width: 0; }
|
||||
/* The board's window: it owns the fitted size, the svg wears an
|
||||
* explicit width (fit × zoom), and anything past 1× scrolls. */
|
||||
.board-viewport {
|
||||
height: calc(100dvh - 21.5rem);
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
}
|
||||
.board-zone :global(svg.board) {
|
||||
max-height: calc(100dvh - 21.5rem);
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
.fpv-primary .board-viewport { height: 30dvh; }
|
||||
.board-viewport :global(svg.board) {
|
||||
width: calc(var(--fit-w, 100%) * var(--zoom, 1));
|
||||
flex: none;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
height: auto;
|
||||
margin: auto;
|
||||
}
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
right: 0.4rem;
|
||||
bottom: 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.zoom-btn {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
border: 1px solid #8a7a5c;
|
||||
border-radius: 4px;
|
||||
background: rgba(233, 225, 203, 0.9);
|
||||
color: #43331f;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zoom-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
|
||||
|
||||
.slip {
|
||||
@@ -3070,6 +3256,7 @@
|
||||
.moment-eye:hover { opacity: 1; }
|
||||
|
||||
.table-edge {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3426,8 +3613,8 @@
|
||||
.board-zone { order: 1; }
|
||||
.table-edge { order: 2; margin-top: 0.6rem; }
|
||||
.paper-rail { order: 3; margin-top: 0.9rem; }
|
||||
.board-zone :global(svg.board) {
|
||||
max-height: 56dvh;
|
||||
.board-viewport {
|
||||
height: 56dvh;
|
||||
}
|
||||
.chronicle { max-height: 30dvh; flex: initial; }
|
||||
.masthead { gap: 0.45rem; padding-top: 0.5rem; }
|
||||
|
||||
@@ -75,9 +75,17 @@
|
||||
effects?: BoardFx[] | null;
|
||||
/** The sight line an attack in progress traveled — proof against "how
|
||||
* can he even see me?", drawn leg by leg through any warp mouth. */
|
||||
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { 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 }; pierced?: string } | null;
|
||||
} = $props();
|
||||
|
||||
/** The wall segment a VISIONSTONE shot pierced, from its edge key. */
|
||||
function piercedSegment(key: string): { x1: number; y1: number; x2: number; y2: number } {
|
||||
const [kind, xy] = key.split(":") as [string, string];
|
||||
const [x, y] = xy.split(",").map(Number) as [number, number];
|
||||
return kind === "V"
|
||||
? { x1: (x + 1) * CELL, y1: y * CELL, x2: (x + 1) * CELL, y2: (y + 1) * CELL }
|
||||
: { x1: x * CELL, y1: (y + 1) * CELL, x2: (x + 1) * CELL, y2: (y + 1) * CELL };
|
||||
}
|
||||
|
||||
const SECTOR = 5;
|
||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||
@@ -701,6 +709,11 @@
|
||||
{:else}
|
||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
||||
{/if}
|
||||
{#if sightTrace.pierced}
|
||||
<!-- the one wall VISIONSTONE dissolved for this shot -->
|
||||
{@const seg = piercedSegment(sightTrace.pierced)}
|
||||
<line x1={seg.x1} y1={seg.y1} x2={seg.x2} y2={seg.y2} class="sight-pierced" />
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||
<g class="fx-layer" aria-hidden="true">
|
||||
@@ -804,6 +817,13 @@
|
||||
stroke-width: 2;
|
||||
animation: sight-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.sight-pierced {
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 2 5;
|
||||
animation: sight-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sight-pulse {
|
||||
0%, 100% { opacity: 0.9; }
|
||||
50% { opacity: 0.35; }
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
|
||||
import { deepestFacing } from "./fpv/director";
|
||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||
import { untrack } from "svelte";
|
||||
import { onDestroy, untrack } from "svelte";
|
||||
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
||||
|
||||
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
|
||||
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, edgeSelect = false, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
|
||||
view: GameView;
|
||||
/** The latest live event batch, numbered so each plays once, with
|
||||
* the view the server sent alongside it. */
|
||||
@@ -34,6 +34,8 @@
|
||||
onfacing?: ((side: Side) => void) | null;
|
||||
/** Cell-card eligibility, shared with the board's dimming aid. */
|
||||
litCells?: Set<string> | null;
|
||||
/** An edge-target card is raised: wall faces offer themselves. */
|
||||
edgeSelect?: boolean;
|
||||
/** The at-your-feet tray's grab: the pane cannot look down, so what
|
||||
* lies in your own square shows as clickable icons instead. */
|
||||
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
|
||||
@@ -222,25 +224,33 @@
|
||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
// --- The fx: each batch plays once, on its own beat. -----------------
|
||||
// Track ONLY the batch: the server sends `events` then `state` back to
|
||||
// back, so a wider dependency set re-runs this within the same frame,
|
||||
// before a zero-delay timer can fire. No per-run cleanup for the same
|
||||
// reason — timers die only with the component.
|
||||
let playedBatch = 0;
|
||||
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
$effect(() => {
|
||||
const b = batch;
|
||||
if (!b || b.n === playedBatch || !me) return;
|
||||
if (!b) return;
|
||||
untrack(() => {
|
||||
if (b.n === playedBatch || !me) return;
|
||||
playedBatch = b.n;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||||
timers.push(setTimeout(() => {
|
||||
started.push(fx.id);
|
||||
const starter = setTimeout(() => {
|
||||
fxTimers.delete(starter);
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||||
}, delay));
|
||||
const ender = setTimeout(() => {
|
||||
fxTimers.delete(ender);
|
||||
fpFx = fpFx.filter((f) => f.id !== fx.id);
|
||||
}, fx.dur + 80);
|
||||
fxTimers.add(ender);
|
||||
}, delay);
|
||||
fxTimers.add(starter);
|
||||
}
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||||
};
|
||||
});
|
||||
});
|
||||
onDestroy(() => fxTimers.forEach(clearTimeout));
|
||||
|
||||
// --- Other bodies glide between views. -------------------------------
|
||||
let prevView: GameView | null = null;
|
||||
@@ -344,12 +354,17 @@
|
||||
// plays only for things done TO you, never for your own hands —
|
||||
// dropping a dagger must not fling your eyes out of your skull.
|
||||
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
|
||||
} else if (aim && !aim.self) {
|
||||
} else if (aim && !aim.self && actor !== povId) {
|
||||
// In the cockpit your own casts never move the camera: casting
|
||||
// through the glass means you are ALREADY facing the target, and
|
||||
// a keymap cast is a deliberate off-screen act. Only deeds done
|
||||
// BY OTHERS earn an aim or a cutaway.
|
||||
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||||
const wantFacing = Math.atan2(ay - ty, ax - tx);
|
||||
const toAim = Math.hypot(ax - tx, ay - ty);
|
||||
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||
const sight = castRay(v, tx, ty, wantFacing);
|
||||
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
|
||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
targetFacing = wantFacing;
|
||||
aimed = true;
|
||||
} else if (performance.now() >= manualUntil) {
|
||||
// Another wizard acting in your sight line turns your head.
|
||||
@@ -455,7 +470,7 @@
|
||||
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||||
fx={fpFx} posOverride={actorPos}
|
||||
ontarget={handleTarget} {litCells} />
|
||||
ontarget={handleTarget} {litCells} {edgeSelect} />
|
||||
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
||||
</div>
|
||||
<div class="bezel-bottom">
|
||||
@@ -511,7 +526,6 @@
|
||||
}
|
||||
.bezel-top { border-bottom: 1px solid #2a2620; }
|
||||
.bezel-bottom { border-top: 1px solid #2a2620; }
|
||||
.bezel-spacer { flex: 1; }
|
||||
.bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; }
|
||||
.bezel-center { justify-content: center; }
|
||||
.bezel-right { justify-content: flex-end; }
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
rubble = [],
|
||||
ontarget,
|
||||
litCells = null,
|
||||
edgeSelect = false,
|
||||
}: {
|
||||
view: GameView;
|
||||
povId: string;
|
||||
@@ -45,6 +46,9 @@
|
||||
/** Squares a selected cell-target card may aim at: the pane dims the
|
||||
* ineligible ground exactly as the board dims its squares. */
|
||||
litCells?: Set<string> | null;
|
||||
/** An edge-target card is raised: every wall and door face glows
|
||||
* faintly, the pane's answer to the board's edge handles. */
|
||||
edgeSelect?: boolean;
|
||||
} = $props();
|
||||
|
||||
const FOV = Math.PI / 2.9;
|
||||
@@ -386,6 +390,12 @@
|
||||
ctx.fillStyle = `rgba(120,70,220,${haze})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
|
||||
// The raised edge-card's invitation: faces breathe gold.
|
||||
const offer = 0.10 + 0.05 * Math.sin(time / 420 + hit.worldU * 3);
|
||||
ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
}
|
||||
|
||||
// Sprites, far to near, sliced against the depth buffer.
|
||||
|
||||
@@ -66,6 +66,8 @@ export function humanize(e: GameEvent): string | null {
|
||||
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
|
||||
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
|
||||
case "tearResisted": return `${e.defender} clutches the treasure with white knuckles — ${e.attacker} comes away empty!`;
|
||||
case "treasureTearAttempted": return `${e.attacker} grabs for the treasure in ${e.defender}'s arms!`;
|
||||
case "powerstoneBoost": return `The powerstone hums over the number already played — ${e.player} gains a stride (${e.newAllowance} total).`;
|
||||
case "trapSprung": return e.cardId === "gift-from-below"
|
||||
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
|
||||
: `${e.player} walked into an old TRAP! Lose a turn.`;
|
||||
@@ -156,6 +158,7 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
|
||||
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
|
||||
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
|
||||
case "boobytrapBlank": return `${e.player} flips a face-down token — a blank. The rest still wait.`;
|
||||
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
|
||||
case "safeCreated": return `A massive safe slams down around the loot.`;
|
||||
case "safeOpened": return null;
|
||||
@@ -275,6 +278,11 @@ function saveSeats(seats: Seat[]): void {
|
||||
|
||||
class Net {
|
||||
status = $state<"disconnected" | "connected">("disconnected");
|
||||
/** When the socket last spoke. A server restart can leave a half-dead
|
||||
* socket that never fires onclose; the watchdog closes it by hand so
|
||||
* the reconnect loop (and the masthead banner) actually engage. */
|
||||
private lastHeard = Date.now();
|
||||
private watchdog: ReturnType<typeof setInterval> | null = null;
|
||||
roomId = $state<string | null>(null);
|
||||
players = $state<string[]>([]);
|
||||
hostId = $state<string | null>(null);
|
||||
@@ -337,6 +345,14 @@ class Net {
|
||||
if (this.ws) return;
|
||||
const ws = new WebSocket(SERVER_URL);
|
||||
this.ws = ws;
|
||||
this.lastHeard = Date.now();
|
||||
if (!this.watchdog) {
|
||||
this.watchdog = setInterval(() => {
|
||||
if (!this.ws || this.status !== "connected") return;
|
||||
this.send({ type: "ping" });
|
||||
if (Date.now() - this.lastHeard > 45_000) this.ws.close();
|
||||
}, 15_000);
|
||||
}
|
||||
ws.onopen = () => {
|
||||
this.watching = null; // the first state after (re)connecting may carry a gap
|
||||
this.status = "connected";
|
||||
@@ -365,7 +381,10 @@ class Net {
|
||||
};
|
||||
ws.onmessage = (raw) => {
|
||||
const msg = JSON.parse(raw.data as string);
|
||||
this.lastHeard = Date.now();
|
||||
switch (msg.type) {
|
||||
case "pong":
|
||||
break;
|
||||
case "seat": {
|
||||
this.token = msg.token;
|
||||
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface Prefs {
|
||||
instantReplay: boolean;
|
||||
/** The live first-person pane above the board during play. */
|
||||
liveFp: boolean;
|
||||
/** The hand of cards stands in a column at the board's left instead of
|
||||
* lying along the bottom (wide screens only). */
|
||||
handLeft: boolean;
|
||||
}
|
||||
|
||||
const KEY = "wizwar-prefs";
|
||||
@@ -29,7 +32,7 @@ const KEY = "wizwar-prefs";
|
||||
function load(): Prefs {
|
||||
const fallback: Prefs = {
|
||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
||||
cautions: true, botTier: "adept", instantReplay: true, liveFp: false,
|
||||
cautions: true, botTier: "adept", instantReplay: true, liveFp: false, handLeft: false,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
@@ -45,6 +48,7 @@ function load(): Prefs {
|
||||
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
||||
instantReplay: p.instantReplay !== false,
|
||||
liveFp: p.liveFp === true,
|
||||
handLeft: p.handLeft === true,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
|
||||
Reference in New Issue
Block a user