Compare commits
45
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
|
- Grab-turn-one openings feel great and prove nothing: deliveries are
|
||||||
the only score. He banked quietly both times while I showboated.
|
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
|
## Playing well
|
||||||
|
|
||||||
- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for
|
- 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
|
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
|
debug log until he asked why I was making it easy.) Trash talk freely
|
||||||
— about the board, never your hand.
|
— 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.
|
- 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
|
## Cadence
|
||||||
|
|
||||||
1. On "go": `view`, read HIS events since your last turn (the client
|
1. On "go": `view`, read HIS events since your last turn (the client
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ function pathToward(
|
|||||||
view: GameView,
|
view: GameView,
|
||||||
from: Cell,
|
from: Cell,
|
||||||
goals: Set<string>,
|
goals: Set<string>,
|
||||||
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {},
|
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean; throughJails?: boolean } = {},
|
||||||
): PathResult | null {
|
): PathResult | null {
|
||||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||||
const enemyCells = livingEnemies(view).map((p) => p.position);
|
const enemyCells = livingEnemies(view).map((p) => p.position);
|
||||||
@@ -611,9 +611,14 @@ function pathToward(
|
|||||||
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
||||||
if (goals.has(k)) { found = k; break; }
|
if (goals.has(k)) { found = k; break; }
|
||||||
// Hazards end a clean path but are waded through when asked to.
|
// 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;
|
const hazard = view.squareContents[k]?.kind;
|
||||||
|
if (hazard === "thornbush" && !opts.throughJails) continue;
|
||||||
if (!opts.throughHazards &&
|
if (!opts.throughHazards &&
|
||||||
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
(hazard === "pit" || hazard === "ooze" ||
|
||||||
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
|
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
|
||||||
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||||
next.push(to);
|
next.push(to);
|
||||||
@@ -770,6 +775,16 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
const atk = attackId ? ATTACKS[attackId] : null;
|
const atk = attackId ? ATTACKS[attackId] : null;
|
||||||
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||||
|
|
||||||
|
// 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)) {
|
if (attackId && UTILITY_ATTACKS.has(attackId)) {
|
||||||
const stopper = find("full-shield") ?? find("force-field");
|
const stopper = find("full-shield") ?? find("force-field");
|
||||||
const stakes =
|
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
|
if (!tier.buffs) return null; // the apprentice's book ends at the stones
|
||||||
// A curse on the clockwork gets scrubbed off.
|
// A curse on the clockwork gets scrubbed off. The engine wants the
|
||||||
const cursed = view.sustained.some(
|
// 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,
|
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
|
||||||
);
|
);
|
||||||
const cleanse = inHand(view, "remove-curse");
|
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.
|
// Three or more wizards: LIFESAVER takes elimination off the table.
|
||||||
const lifesaver = inHand(view, "lifesaver");
|
const lifesaver = inHand(view, "lifesaver");
|
||||||
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
|
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.
|
// wizard hauling gold, anyone bleeding out.
|
||||||
const pressed = enemyNear &&
|
const pressed = enemyNear &&
|
||||||
(style === "worrier" || self.carriedTreasureId != null || self.life <= 5);
|
(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") {
|
if (pressed && style !== "berserker") {
|
||||||
// FEAR keeps every pursuer three spaces off.
|
// FEAR keeps every pursuer three spaces off.
|
||||||
const scare = inHand(view, "fear");
|
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) {
|
if (tier.buffs && !view.turn.attackUsed) {
|
||||||
const smallest = numbersInHand(view)[0];
|
const smallest = numbersInHand(view)[0];
|
||||||
// ADRENALINE: when no single blow finishes the target but two would.
|
// 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.
|
// ends the march); otherwise the gold has its legs for the turn.
|
||||||
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
|
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
|
||||||
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
|
!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
|
// 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
|
// 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.
|
// am, with the bank stocked, outranks the next grab — run home.
|
||||||
@@ -1565,11 +1618,17 @@ export function automatonCommand(
|
|||||||
const path =
|
const path =
|
||||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
|
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
|
||||||
pathToward(view, self.position, objectives, { 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 }) ??
|
pathToward(view, self.position, enemyCells, { canUnlock }) ??
|
||||||
// No clean road to anything: grit the teeth and wade the hazards,
|
// No clean road to anything: grit the teeth and wade the hazards,
|
||||||
// as any wizard would rather than stand in a pocket forever.
|
// 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:
|
// A shimmering illusion on the best crossing costs nothing to doubt:
|
||||||
// roll the free test before spending any card on that wall.
|
// roll the free test before spending any card on that wall.
|
||||||
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
||||||
|
|||||||
@@ -480,3 +480,34 @@ export function sightBetween(
|
|||||||
return hasLineOfSight(board, from, to, blockedCells) ||
|
return hasLineOfSight(board, from, to, blockedCells) ||
|
||||||
hasWarpLineOfSight(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
-91
@@ -24,6 +24,7 @@ import {
|
|||||||
type Warp,
|
type Warp,
|
||||||
hasLineOfSight,
|
hasLineOfSight,
|
||||||
sightBetween,
|
sightBetween,
|
||||||
|
bentSightThroughGap,
|
||||||
neighbor,
|
neighbor,
|
||||||
stepTarget,
|
stepTarget,
|
||||||
opposite,
|
opposite,
|
||||||
@@ -211,6 +212,9 @@ export interface CastStack {
|
|||||||
creatureTouch?: "wraith" | "claw";
|
creatureTouch?: "wraith" | "claw";
|
||||||
/** A spell freed from slime: counteractions cannot touch its caster. */
|
/** A spell freed from slime: counteractions cannot touch its caster. */
|
||||||
trapped?: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CastParams {
|
export interface CastParams {
|
||||||
@@ -229,7 +233,7 @@ export interface CastParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
/** 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 {
|
export interface GameConfig {
|
||||||
playerIds: PlayerId[];
|
playerIds: PlayerId[];
|
||||||
@@ -253,6 +257,24 @@ export interface GameConfig {
|
|||||||
* it; unpassed, it relocks at turn's end as before.
|
* it; unpassed, it relocks at turn's end as before.
|
||||||
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
|
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
|
||||||
* in three spaces, instead of eating the card silently at resolution.
|
* 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;
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
@@ -388,21 +410,25 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
|
|||||||
return edges ? { ...board, edges } : board;
|
return edges ? { ...board, edges } : board;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LOS including square-filling blockers. */
|
|
||||||
/** Sight granted by removing any ONE closed edge — the VISIONSTONE's
|
/** Sight granted by removing any ONE closed edge — the VISIONSTONE's
|
||||||
* whole power, shared by every path that honors it. */
|
* whole power, shared by every path that honors it. `sees` judges the
|
||||||
function sightThroughOneEdge(
|
* board with the candidate edge gone. */
|
||||||
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
|
function throughOneEdge(board: AssembledBoard, sees: (b: AssembledBoard) => boolean): boolean {
|
||||||
): boolean {
|
|
||||||
for (const key of Object.keys(board.edges)) {
|
for (const key of Object.keys(board.edges)) {
|
||||||
if ((board.edges[key] ?? "open") === "open") continue;
|
if ((board.edges[key] ?? "open") === "open") continue;
|
||||||
const edges = { ...board.edges };
|
const edges = { ...board.edges };
|
||||||
delete edges[key];
|
delete edges[key];
|
||||||
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
|
if (sees({ ...board, edges })) return true;
|
||||||
}
|
}
|
||||||
return false;
|
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 {
|
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
|
||||||
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
||||||
const blockers = losBlockers(state);
|
const blockers = losBlockers(state);
|
||||||
@@ -483,8 +509,10 @@ function casterLos(
|
|||||||
return sightThroughOneEdge(board, from, to, blockers);
|
return sightThroughOneEdge(board, from, to, blockers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
/** Bent LOS for AROUND THE CORNER: the caster sees a middle cell which sees
|
||||||
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean {
|
* 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;
|
if (casterLos(state, caster, from, to)) return true;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
for (const key of Object.keys(view.cells)) {
|
for (const key of Object.keys(view.cells)) {
|
||||||
@@ -494,6 +522,49 @@ function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, ev
|
|||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,11 +615,11 @@ export type GameEvent =
|
|||||||
| { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string }
|
| { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string }
|
||||||
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
|
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
|
||||||
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: 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: "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;
|
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
|
||||||
soaks?: { what: "bloodstone" | "soulstone"; amount: 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: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number }
|
||||||
| { type: "stunned"; player: PlayerId; turnsLost: number }
|
| { type: "stunned"; player: PlayerId; turnsLost: number }
|
||||||
| { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number }
|
| { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number }
|
||||||
@@ -619,6 +690,7 @@ export type GameEvent =
|
|||||||
| { type: "stuckInSlime"; player: PlayerId; at: Cell }
|
| { type: "stuckInSlime"; player: PlayerId; at: Cell }
|
||||||
| { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] }
|
| { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] }
|
||||||
| { type: "boobytrapSprung"; player: PlayerId; at: Cell }
|
| { type: "boobytrapSprung"; player: PlayerId; at: Cell }
|
||||||
|
| { type: "boobytrapBlank"; player: PlayerId; at: Cell }
|
||||||
| { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell }
|
| { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell }
|
||||||
| { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number }
|
| { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number }
|
||||||
| { type: "safeCreated"; caster: PlayerId; at: Cell }
|
| { type: "safeCreated"; caster: PlayerId; at: Cell }
|
||||||
@@ -670,6 +742,8 @@ export type GameEvent =
|
|||||||
| { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell }
|
| { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell }
|
||||||
| { type: "treasureTorn"; attacker: PlayerId; defender: PlayerId; treasureId: string; toFloor: boolean }
|
| { type: "treasureTorn"; attacker: PlayerId; defender: PlayerId; treasureId: string; toFloor: boolean }
|
||||||
| { type: "tearResisted"; attacker: PlayerId; defender: PlayerId }
|
| { 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: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null }
|
||||||
| { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" }
|
| { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" }
|
||||||
| { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] }
|
| { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] }
|
||||||
@@ -755,6 +829,10 @@ type AttackEffect = {
|
|||||||
baseDamage: (numberValue: number | null, params: CastParams | null) => number;
|
baseDamage: (numberValue: number | null, params: CastParams | null) => number;
|
||||||
/** A duration spell: attach a sustained effect on resolution. */
|
/** A duration spell: attach a sustained effect on resolution. */
|
||||||
sustains?: boolean;
|
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). */
|
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
|
||||||
keepInHand?: boolean;
|
keepInHand?: boolean;
|
||||||
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
|
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
|
||||||
@@ -795,6 +873,8 @@ interface ResolutionContext {
|
|||||||
defender: PlayerState;
|
defender: PlayerState;
|
||||||
damageDealt: number;
|
damageDealt: number;
|
||||||
fullyStopped: boolean;
|
fullyStopped: boolean;
|
||||||
|
/** A surviving REVERSE rode the stack: the spell's effect runs backward. */
|
||||||
|
reversed: boolean;
|
||||||
duration: number;
|
duration: number;
|
||||||
stack: CastStack;
|
stack: CastStack;
|
||||||
}
|
}
|
||||||
@@ -1163,7 +1243,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
}
|
}
|
||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
|
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.edgeOverrides[key] = "wall";
|
||||||
state.createdEdges[key] = true;
|
state.createdEdges[key] = true;
|
||||||
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
||||||
@@ -1187,7 +1267,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
const current = view.edges[key] ?? "open";
|
const current = view.edges[key] ?? "open";
|
||||||
if (current === "open") return "there is no wall there";
|
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";
|
state.edgeOverrides[key] = "open";
|
||||||
delete state.doorStates[key];
|
delete state.doorStates[key];
|
||||||
delete state.createdEdges[key];
|
delete state.createdEdges[key];
|
||||||
@@ -1376,7 +1456,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
|
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
|
||||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||||
if (!target || !target.alive) return "no such living player";
|
if (!target || !target.alive) return "no such living player";
|
||||||
if (!gameLos(state, caster.position, target.position, 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.
|
// Effectively permanent: broken by the caster attacking the target.
|
||||||
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
||||||
return null;
|
return null;
|
||||||
@@ -1415,8 +1495,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
ctx.defender.carriedTreasureId = null;
|
ctx.defender.carriedTreasureId = null;
|
||||||
ctx.events.push({
|
ctx.events.push({
|
||||||
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
|
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
|
||||||
@@ -1475,7 +1559,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (farKey && (view.edges[farKey] ?? "open") !== "open") {
|
if (farKey && (view.edges[farKey] ?? "open") !== "open") {
|
||||||
return "the far mouth of that warp is sealed";
|
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 }) => {
|
const ignite = (k: string, at: { cell: Cell; side: Side }) => {
|
||||||
state.edgeOverrides[k] = "firewall";
|
state.edgeOverrides[k] = "firewall";
|
||||||
state.createdEdges[k] = true;
|
state.createdEdges[k] = true;
|
||||||
@@ -1511,7 +1595,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
||||||
return "the wave must span a corridor between two spaces";
|
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 } });
|
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
|
||||||
// The two sides of the edge, and the push directions away from it.
|
// The two sides of the edge, and the push directions away from it.
|
||||||
waveFromEdge(state, events, cell, side, 2, "waterwall");
|
waveFromEdge(state, events, cell, side, 2, "waterwall");
|
||||||
@@ -1526,13 +1610,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (cmd.target?.kind === "edge") {
|
if (cmd.target?.kind === "edge") {
|
||||||
const key = edgeKey(cmd.target.cell, cmd.target.side);
|
const key = edgeKey(cmd.target.cell, cmd.target.side);
|
||||||
if (state.illusionWalls[key]) {
|
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];
|
delete state.illusionWalls[key];
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
|
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!state.createdEdges[key]) return "that is not a created thing";
|
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];
|
const was = view.edges[key];
|
||||||
delete state.edgeOverrides[key];
|
delete state.edgeOverrides[key];
|
||||||
delete state.createdEdges[key];
|
delete state.createdEdges[key];
|
||||||
@@ -1544,14 +1628,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
const creature = creatureAt(state, cmd.target.cell);
|
const creature = creatureAt(state, cmd.target.cell);
|
||||||
if (creature) {
|
if (creature) {
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell, 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");
|
destroyCreature(state, events, creature, "dispel creation");
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const content = state.squareContents[key];
|
const content = state.squareContents[key];
|
||||||
if (!content) return "nothing created there";
|
if (!content) return "nothing created there";
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||||
delete state.squareContents[key];
|
delete state.squareContents[key];
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
||||||
return null;
|
return null;
|
||||||
@@ -1568,7 +1652,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||||
if (!target || !target.alive) return "no such living player";
|
if (!target || !target.alive) return "no such living player";
|
||||||
if (target.id === caster.id) return "you cannot drag yourself";
|
if (target.id === caster.id) return "you cannot drag yourself";
|
||||||
if (!gameLos(state, caster.position, target.position, 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";
|
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
||||||
const from = target.position;
|
const from = target.position;
|
||||||
dragToward(state, target, caster.position);
|
dragToward(state, target, caster.position);
|
||||||
@@ -1577,7 +1661,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
}
|
}
|
||||||
if (cmd.target?.kind === "cell") {
|
if (cmd.target?.kind === "cell") {
|
||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell, 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 objects = state.groundObjects[key];
|
||||||
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
||||||
if (objects && objects.length > 0) {
|
if (objects && objects.length > 0) {
|
||||||
@@ -1603,7 +1687,15 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
},
|
},
|
||||||
// --- Magic stones ---------------------------------------------------------
|
// --- Magic stones ---------------------------------------------------------
|
||||||
bloodstone: stoneEffect(),
|
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(),
|
shadowstone: stoneEffect(),
|
||||||
soulstone: stoneEffect(),
|
soulstone: stoneEffect(),
|
||||||
speedstone: stoneEffect((state, _events, caster) => {
|
speedstone: stoneEffect((state, _events, caster) => {
|
||||||
@@ -1635,17 +1727,23 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
kind: "attack",
|
kind: "attack",
|
||||||
requiresLos: true,
|
requiresLos: true,
|
||||||
baseDamage: () => 0,
|
baseDamage: () => 0,
|
||||||
|
permanentCurse: true,
|
||||||
// "This is permanent. Once SLOW DEATH is on, it can't be turned off."
|
// "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) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
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 },
|
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
|
||||||
"around-the-corner": {
|
"around-the-corner": {
|
||||||
kind: "neutral",
|
kind: "neutral",
|
||||||
// Never cast alone — attached to an attack via aroundCornerInstanceId.
|
// Never cast alone — attached to any L.O.S. cast via aroundCornerInstanceId.
|
||||||
resolve: () => "attach Around The Corner to an attack instead of casting it alone",
|
resolve: () => "attach Around The Corner to a line-of-sight spell instead of casting it alone",
|
||||||
},
|
},
|
||||||
ugly: {
|
ugly: {
|
||||||
kind: "neutral",
|
kind: "neutral",
|
||||||
@@ -1678,7 +1776,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
|
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 (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: {} };
|
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
|
||||||
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
|
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
|
||||||
return null;
|
return null;
|
||||||
@@ -1750,7 +1848,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||||
if (!creature) return "no such monster";
|
if (!creature) return "no such monster";
|
||||||
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
|
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
|
||||||
if (!gameLos(state, caster.position, creature.position, 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";
|
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
|
||||||
if (boost === "movement") creature.movesPerTurn *= 2;
|
if (boost === "movement") creature.movesPerTurn *= 2;
|
||||||
else creature.maxDamage *= 2;
|
else creature.maxDamage *= 2;
|
||||||
@@ -1841,7 +1939,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall";
|
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.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
|
||||||
state.edgeOverrides[key] = "open";
|
state.edgeOverrides[key] = "open";
|
||||||
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
|
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
|
||||||
@@ -1917,7 +2015,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
|
if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
|
||||||
return "a door goes into a stone wall or an open corridor";
|
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.edgeOverrides[key] = "door";
|
||||||
state.createdEdges[key] = true;
|
state.createdEdges[key] = true;
|
||||||
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
||||||
@@ -1937,8 +2035,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
}
|
}
|
||||||
const uniq = new Set(cells.map(cellKey));
|
const uniq = new Set(cells.map(cellKey));
|
||||||
if (uniq.size !== 4) return "the four tokens go on four different squares";
|
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]!) });
|
// The FIRST cell in the command is the real trap — but that truth
|
||||||
events.push({ type: "boobytrapPlaced", caster: caster.id, cells: [...cells] });
|
// 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]! });
|
events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! });
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -1952,7 +2055,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
(state.groundObjects[key] ?? []).length > 0 ||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
if (!hasObject) return "there is nothing there to glue down";
|
if (!hasObject) return "there is nothing there to glue down";
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||||
state.gluedCells[key] = true;
|
state.gluedCells[key] = true;
|
||||||
// "a duration equal to twice the NUMBER card played"
|
// "a duration equal to twice the NUMBER card played"
|
||||||
const turns = magnitude.duration * 2;
|
const turns = magnitude.duration * 2;
|
||||||
@@ -1976,7 +2079,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
(state.groundObjects[key] ?? []).length > 0 ||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
if (!hasObject) return "there is nothing there to lock up";
|
if (!hasObject) return "there is nothing there to lock up";
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell, 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 };
|
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
|
||||||
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
||||||
return null;
|
return null;
|
||||||
@@ -1994,7 +2097,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (ka === kb) return "pick two different squares";
|
if (ka === kb) return "pick two different squares";
|
||||||
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
||||||
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
|
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
|
||||||
if (!gameLos(state, caster.position, a, 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 itemsA = state.groundObjects[ka] ?? [];
|
||||||
const itemsB = state.groundObjects[kb] ?? [];
|
const itemsB = state.groundObjects[kb] ?? [];
|
||||||
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
||||||
@@ -2026,7 +2129,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const current = view.edges[key];
|
const current = view.edges[key];
|
||||||
const meltable = current === "wall" || current === "door";
|
const meltable = current === "wall" || current === "door";
|
||||||
if (!meltable) return "that is not a stone wall";
|
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";
|
state.edgeOverrides[key] = "open";
|
||||||
delete state.doorStates[key];
|
delete state.doorStates[key];
|
||||||
delete state.createdEdges[key];
|
delete state.createdEdges[key];
|
||||||
@@ -2057,7 +2160,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (cmd.target?.kind === "cell") {
|
if (cmd.target?.kind === "cell") {
|
||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
|
||||||
delete state.squareContents[key];
|
delete state.squareContents[key];
|
||||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
||||||
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
|
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
|
||||||
@@ -2115,13 +2218,21 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
kind: "attack",
|
kind: "attack",
|
||||||
requiresLos: true,
|
requiresLos: true,
|
||||||
baseDamage: () => 0,
|
baseDamage: () => 0,
|
||||||
|
permanentCurse: true,
|
||||||
// "1/2 point of damage for every space moved. This spell is permanent."
|
// "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) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
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: {
|
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",
|
kind: "attack",
|
||||||
baseDamage: () => 0,
|
baseDamage: () => 0,
|
||||||
sameSquare: false,
|
sameSquare: false,
|
||||||
@@ -2208,8 +2319,35 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
kind: "attack",
|
kind: "attack",
|
||||||
physical: true,
|
physical: true,
|
||||||
baseDamage: () => 0, // computed at resolution: distance rammed
|
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) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
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) +
|
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
|
||||||
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
|
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
|
||||||
if (d === 0) return;
|
if (d === 0) return;
|
||||||
@@ -2429,6 +2567,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
kind: "attack",
|
kind: "attack",
|
||||||
requiresLos: true,
|
requiresLos: true,
|
||||||
baseDamage: () => 0,
|
baseDamage: () => 0,
|
||||||
|
permanentCurse: true,
|
||||||
// "Opponent heads straight for the nearest of his own treasures ... This
|
// "Opponent heads straight for the nearest of his own treasures ... This
|
||||||
// lasts until opponent is on his own treasure."
|
// lasts until opponent is on his own treasure."
|
||||||
sustains: false,
|
sustains: false,
|
||||||
@@ -2441,6 +2580,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
|
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
|
||||||
if (carried?.owner === ctx.defender.id) return;
|
if (carried?.owner === ctx.defender.id) return;
|
||||||
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
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": {
|
"big-man": {
|
||||||
@@ -2526,7 +2667,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const aim = cmd.target.cell;
|
const aim = cmd.target.cell;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
if (!view.cells[cellKey(aim)]) return "off the board";
|
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;
|
state.turn.attackUsed = true;
|
||||||
|
|
||||||
const clampToBoard = (c: Cell): Cell => {
|
const clampToBoard = (c: Cell): Cell => {
|
||||||
@@ -2758,7 +2899,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
|
|||||||
if (!view.cells[cellKey(at)]) return "off the board";
|
if (!view.cells[cellKey(at)]) return "off the board";
|
||||||
if (state.squareContents[cellKey(at)]) return "that square is blocked";
|
if (state.squareContents[cellKey(at)]) return "that square is blocked";
|
||||||
if (creatureAt(state, at)) return "a creature is already there";
|
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;
|
state.turn.attackUsed = true;
|
||||||
spawnCreature(state, events, kind, caster.id, at);
|
spawnCreature(state, events, kind, caster.id, at);
|
||||||
return null;
|
return null;
|
||||||
@@ -2810,7 +2951,7 @@ function emptySquareTarget(
|
|||||||
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
||||||
return "the warp shimmers there — nothing can form on it";
|
return "the warp shimmers there — nothing can form on it";
|
||||||
}
|
}
|
||||||
if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight";
|
if (!castSight(state, caster, cmd, cell)) return "no line of sight";
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2937,7 +3078,20 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
|
|||||||
if (!gameLos(state, imp.position, p.position)) continue;
|
if (!gameLos(state, imp.position, p.position)) continue;
|
||||||
imp.scorchedThisTurn.push(p.id);
|
imp.scorchedThisTurn.push(p.id);
|
||||||
events.push({ type: "impScorches", creatureId: imp.id, player: p.id });
|
events.push({ type: "impScorches", creatureId: imp.id, player: p.id });
|
||||||
applyDamage(state, events, p, 2, "fire imp", null);
|
// 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);
|
checkVictory(state, events);
|
||||||
@@ -3095,21 +3249,35 @@ function openCreatureStack(
|
|||||||
victim: PlayerState,
|
victim: PlayerState,
|
||||||
damage: number,
|
damage: number,
|
||||||
touch?: "wraith" | "claw",
|
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 {
|
): void {
|
||||||
state.stack = {
|
state.stack = {
|
||||||
attackerId: creature.controllerId,
|
attackerId,
|
||||||
defenderId: victim.id,
|
defenderId,
|
||||||
attackCard: null,
|
attackCard: null,
|
||||||
numberValue: null,
|
numberValue: null,
|
||||||
amplifyFactor: 1,
|
amplifyFactor: 1,
|
||||||
extendFactor: 1,
|
extendFactor: 1,
|
||||||
powerAttackPoints: 0,
|
powerAttackPoints: 0,
|
||||||
params: { damage },
|
|
||||||
kind: "physical",
|
|
||||||
counters: [],
|
counters: [],
|
||||||
waitingOn: victim.id,
|
waitingOn: defenderId,
|
||||||
creatureId: creature.id,
|
...opts,
|
||||||
...(touch ? { creatureTouch: touch } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3441,6 +3609,7 @@ function attachSustained(
|
|||||||
casterId: PlayerId,
|
casterId: PlayerId,
|
||||||
targetId: PlayerId,
|
targetId: PlayerId,
|
||||||
turns: number,
|
turns: number,
|
||||||
|
data: Record<string, number> = {},
|
||||||
): void {
|
): void {
|
||||||
const effect: SustainedEffect = {
|
const effect: SustainedEffect = {
|
||||||
id: `fx-${state.nextEffectId++}`,
|
id: `fx-${state.nextEffectId++}`,
|
||||||
@@ -3448,7 +3617,7 @@ function attachSustained(
|
|||||||
casterId,
|
casterId,
|
||||||
targetId,
|
targetId,
|
||||||
remainingTurns: Math.max(1, turns),
|
remainingTurns: Math.max(1, turns),
|
||||||
data: {},
|
data,
|
||||||
};
|
};
|
||||||
state.sustained.push(effect);
|
state.sustained.push(effect);
|
||||||
events.push({
|
events.push({
|
||||||
@@ -4148,12 +4317,18 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
state.turn.movementUsed++;
|
state.turn.movementUsed++;
|
||||||
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
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")) {
|
for (const fx of sustainedOn(state, p.id, "walking-dead")) {
|
||||||
fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1;
|
fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1;
|
||||||
if (fx.data.halfSteps % 2 === 0) {
|
if (fx.data.halfSteps % 2 === 0) {
|
||||||
applyDamage(state, events, p, 1, "walking dead", null);
|
if (fx.data.reversed) {
|
||||||
checkVictory(state, events);
|
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.
|
// DISEASE: the carrier infects every other player in a square they enter.
|
||||||
@@ -4165,6 +4340,18 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
}
|
}
|
||||||
checkVictory(state, events);
|
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) {
|
if (crossedFirewall) {
|
||||||
events.push({ type: "firewallBurned", player: p.id });
|
events.push({ type: "firewallBurned", player: p.id });
|
||||||
@@ -4226,31 +4413,25 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
state.turn.actionsEnded = true;
|
state.turn.actionsEnded = true;
|
||||||
springSlimeTrap(state, events, p);
|
springSlimeTrap(state, events, p);
|
||||||
}
|
}
|
||||||
// IDIOT lifts when the victim reaches their own treasure.
|
liftIdiotIfSatisfied(state, events, p);
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Armed ambushes may spring on this step.
|
// Armed ambushes may spring on this step.
|
||||||
checkAmbushes(state, events, p, { movedFrom: from });
|
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]) {
|
for (const trap of [...state.boobytraps]) {
|
||||||
if (trap.casterId === p.id) continue;
|
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);
|
state.boobytraps = state.boobytraps.filter((t) => t !== trap);
|
||||||
events.push({ type: "boobytrapSprung", player: p.id, at: p.position });
|
events.push({ type: "boobytrapSprung", player: p.id, at: p.position });
|
||||||
applyDamage(state, events, p, 4, "boobytrap", null, "physical");
|
applyDamage(state, events, p, 4, "boobytrap", null, "physical");
|
||||||
checkVictory(state, events);
|
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 +4562,22 @@ function castingBlocked(
|
|||||||
* treasure on their own home, or drop their own treasure underfoot for an
|
* treasure on their own home, or drop their own treasure underfoot for an
|
||||||
* instant cure.
|
* 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 {
|
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||||
if (sustainedOn(state, playerId, "idiot").length > 0) {
|
if (sustainedOn(state, playerId, "idiot").length > 0) {
|
||||||
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
||||||
@@ -4460,8 +4657,18 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
|||||||
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
|
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
|
||||||
state.discard.push(card!);
|
state.discard.push(card!);
|
||||||
events.push({ type: "wardSprung", owner: owner.id, victim: taker.id });
|
events.push({ type: "wardSprung", owner: owner.id, victim: taker.id });
|
||||||
applyDamage(state, events, taker, 3, "warded treasure", null);
|
// Rev 9: "COUNTERACTIONs against WARD ... otherwise work as written" —
|
||||||
checkVictory(state, events);
|
// 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,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
applyDamage(state, events, taker, 3, "warded treasure", null);
|
||||||
|
checkVictory(state, events);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
@@ -4579,10 +4786,31 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
|
|||||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
state.turn.attackUsed = 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");
|
const roll = rollD4(state, events, target.id, "clutching the treasure — a 1 keeps it");
|
||||||
if (roll === 1) {
|
if (roll === 1) {
|
||||||
events.push({ type: "tearResisted", attacker: attacker.id, defender: target.id });
|
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)!;
|
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
|
||||||
@@ -4612,7 +4840,6 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
return { ok: true, state, events };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
|
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
|
||||||
@@ -4896,6 +5123,22 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
return null;
|
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") {
|
if (effect.kind === "attack") {
|
||||||
const pre = attackPreconditions(state);
|
const pre = attackPreconditions(state);
|
||||||
if (pre) return err(pre);
|
if (pre) return err(pre);
|
||||||
@@ -4907,7 +5150,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
|
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
|
||||||
return err("you must be in the same square");
|
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");
|
return err("no line of sight to the creature");
|
||||||
}
|
}
|
||||||
const wandEvents: GameEvent[] = [];
|
const wandEvents: GameEvent[] = [];
|
||||||
@@ -4943,7 +5188,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (cmd.target?.kind === "cell" &&
|
if (cmd.target?.kind === "cell" &&
|
||||||
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
|
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
|
||||||
const cell = cmd.target.cell;
|
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");
|
return err("no line of sight to the slime");
|
||||||
}
|
}
|
||||||
const wandEvents: GameEvent[] = [];
|
const wandEvents: GameEvent[] = [];
|
||||||
@@ -4990,7 +5237,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
|
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
|
||||||
return err("you must stand beside the wall");
|
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");
|
return err("no line of sight to the wall");
|
||||||
}
|
}
|
||||||
const dmg =
|
const dmg =
|
||||||
@@ -5062,7 +5309,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
const preEvents: GameEvent[] = [];
|
const preEvents: GameEvent[] = [];
|
||||||
if (effect.requiresLos) {
|
if (effect.requiresLos) {
|
||||||
const sighted = mods.aroundCorner
|
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);
|
: casterLos(state, caster, caster.position, target.position);
|
||||||
if (!sighted) return err("no line of sight to the target");
|
if (!sighted) return err("no line of sight to the target");
|
||||||
}
|
}
|
||||||
@@ -5183,7 +5430,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Neutral: validate on a preview clone before consuming any cards.
|
// 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",
|
type: "spellCast",
|
||||||
caster: caster.id,
|
caster: caster.id,
|
||||||
card: inHand,
|
card: inHand,
|
||||||
@@ -5193,7 +5442,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
from: caster.position,
|
from: caster.position,
|
||||||
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
|
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
|
||||||
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
|
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
|
||||||
}];
|
});
|
||||||
const preview = clone(state);
|
const preview = clone(state);
|
||||||
const problem = (effect as NeutralEffect).resolve(
|
const problem = (effect as NeutralEffect).resolve(
|
||||||
preview, [], activePlayer(preview), cmd, mods.magnitude,
|
preview, [], activePlayer(preview), cmd, mods.magnitude,
|
||||||
@@ -5677,7 +5926,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
? effect.baseDamage(stack.numberValue, stack.params)
|
? effect.baseDamage(stack.numberValue, stack.params)
|
||||||
: stack.creatureId
|
: stack.creatureId
|
||||||
? (stack.params?.damage ?? 1) // a creature's blow
|
? (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."
|
// 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) {
|
if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) {
|
||||||
base += 2;
|
base += 2;
|
||||||
@@ -5801,7 +6052,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
effect.onResolved({
|
effect.onResolved({
|
||||||
state, events,
|
state, events,
|
||||||
attacker: defender, defender: attacker,
|
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 } },
|
stack: { ...stack, params: { ...stack.params, cardId: chosen } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5818,9 +6069,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The returned spell is a fresh attack on its own caster — who gets a
|
// 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 &&
|
if (!stack.trapped && !attackingCreature &&
|
||||||
attacker.alive && (pipe.damage > 0 || pipe.duration > 0)) {
|
attacker.alive && (pipe.damage > 0 || pipe.duration > 0 || returnsCurse)) {
|
||||||
state.stack = {
|
state.stack = {
|
||||||
attackerId: defender.id,
|
attackerId: defender.id,
|
||||||
defenderId: attacker.id,
|
defenderId: attacker.id,
|
||||||
@@ -5907,6 +6160,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({
|
events.push({
|
||||||
type: "attackResolved",
|
type: "attackResolved",
|
||||||
attacker: attacker.id,
|
attacker: attacker.id,
|
||||||
@@ -5926,6 +6188,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
defender,
|
defender,
|
||||||
damageDealt,
|
damageDealt,
|
||||||
fullyStopped: pipe.fullyStopped,
|
fullyStopped: pipe.fullyStopped,
|
||||||
|
reversed: pipe.reversed,
|
||||||
duration: pipe.duration,
|
duration: pipe.duration,
|
||||||
stack,
|
stack,
|
||||||
});
|
});
|
||||||
@@ -6058,10 +6321,24 @@ function applyDamage(
|
|||||||
soaks.push({ what: "bloodstone", amount: 1 });
|
soaks.push({ what: "bloodstone", amount: 1 });
|
||||||
}
|
}
|
||||||
// SOULSTONE: "Last three points ... can only be lost to physical damage."
|
// 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
|
||||||
const clamped = Math.min(amount, target.life - 3);
|
// less takes NOTHING from spells. Earlier revisions clamped only from
|
||||||
if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped });
|
// above (a bearer at 1 could be burned to death), and their ledgers
|
||||||
amount = clamped;
|
// 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;
|
target.life -= amount;
|
||||||
@@ -6140,6 +6417,10 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
|
|||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
|
{ 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" —
|
// WARD: "you may play at that time (out of turn) this card on him" —
|
||||||
// literally: the grab hangs while the owner decides.
|
// literally: the grab hangs while the owner decides.
|
||||||
const owner = state.players.find((q) => q.id === t.owner);
|
const owner = state.players.find((q) => q.id === t.owner);
|
||||||
@@ -6312,11 +6593,18 @@ function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]):
|
|||||||
return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] };
|
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 {
|
function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void {
|
||||||
const stacks = sustainedOn(state, p.id, "slow-death").length;
|
const curses = sustainedOn(state, p.id, "slow-death");
|
||||||
if (stacks === 0 || cardsDrawn === 0 || !p.alive) return;
|
if (curses.length === 0 || cardsDrawn === 0 || !p.alive) return;
|
||||||
for (let i = 0; i < cardsDrawn * stacks; i++) {
|
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;
|
if (!p.alive) break;
|
||||||
applyDamage(state, events, p, 1, "slow death", null);
|
applyDamage(state, events, p, 1, "slow death", null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// The server sends this after every state change; clients never see the
|
// The server sends this after every state change; clients never see the
|
||||||
// deck order or other players' hands.
|
// deck order or other players' hands.
|
||||||
|
|
||||||
import { 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 { cardDef, type CardInstance } from "./cards";
|
||||||
import {
|
import {
|
||||||
boardView,
|
boardView,
|
||||||
@@ -223,6 +223,32 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
|||||||
return out;
|
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. */
|
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
||||||
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
||||||
// Held-open doors are open doorways to every eye. Beyond them, the
|
// 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(
|
export function stackSightTrace(
|
||||||
view: GameView,
|
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;
|
const stack = view.stack;
|
||||||
if (!stack || stack.creatureId) return null;
|
if (!stack || stack.creatureId) return null;
|
||||||
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
||||||
@@ -306,6 +339,19 @@ export function stackSightTrace(
|
|||||||
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
||||||
const trace = traceSightFor(view, a.position, d.position);
|
const trace = traceSightFor(view, a.position, d.position);
|
||||||
if (trace) return { from: a.position, to: d.position, trace };
|
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
|
// 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
|
// ends can see and draw the sight leg by leg, so the table can audit the
|
||||||
// needle instead of doubting it.
|
// 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.
|
* aid — mirroring the engine's own validation from the viewer's knowledge.
|
||||||
* Null = this card's eligibility is not modeled; light everything.
|
* 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);
|
const me = view.players.find((p) => p.id === view.you);
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
const cells = Object.keys(view.board.cells);
|
const cells = Object.keys(view.board.cells);
|
||||||
const key = (x: number, y: number) => `${x},${y}`;
|
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)) {
|
if (CREATION_CARD_IDS.has(cardId)) {
|
||||||
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
// 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 { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
|
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). */
|
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
||||||
/** Advance past round 1 (both players just end their turns). */
|
/** 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 });
|
expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id });
|
||||||
// Spring it: the thief bleeds 3 and the ward is spent.
|
// Spring it: the thief bleeds 3 and the ward is spent.
|
||||||
state = must(state, owner.id, { type: "wardChoice", play: true });
|
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 === thief.id)!.life).toBe(12);
|
||||||
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -1196,3 +1197,158 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
|
|||||||
expect(traced?.bend?.mid).toBeDefined();
|
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";
|
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 { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
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). */
|
/** Summon a creature next to its creator (round 2+, consumes the attack). */
|
||||||
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
||||||
@@ -151,24 +151,24 @@ describe("monsters", () => {
|
|||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const me = activePlayer(state).id;
|
const me = activePlayer(state).id;
|
||||||
const r = summon(state, me, "fire-imp");
|
const r = summon(state, me, "fire-imp");
|
||||||
state = r.state;
|
state = drain(r.state);
|
||||||
const imp = state.creatures[0]!;
|
const imp = state.creatures[0]!;
|
||||||
|
|
||||||
// Fireball cannot destroy it...
|
// 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)!;
|
const enemy = state.players.find((p) => p.id !== me)!;
|
||||||
// (enemy may have been scorched at turn start if in LOS — note life)
|
// (enemy may have been scorched at turn start if in LOS — note life)
|
||||||
const enemyNow = state.players.find((p) => p.id !== me)!;
|
const enemyNow = state.players.find((p) => p.id !== me)!;
|
||||||
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
|
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
|
||||||
const fb = giveCard(state, enemy.id, "fireball", "F", 0);
|
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 },
|
type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id },
|
||||||
});
|
}));
|
||||||
expect(state.creatures.length).toBe(1);
|
expect(state.creatures.length).toBe(1);
|
||||||
|
|
||||||
// ...but a waterbolt douses it instantly.
|
// ...but a waterbolt douses it instantly.
|
||||||
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
state = drain(must(state, enemy.id, { type: "endTurn", draw: 0 }));
|
||||||
state = must(state, me, { 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 };
|
state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position };
|
||||||
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
|
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
|
||||||
state = must(state, enemy.id, {
|
state = must(state, enemy.id, {
|
||||||
@@ -787,3 +787,59 @@ describe("creatures and the dimensional warp", () => {
|
|||||||
if (!r.ok) expect(r.error).toContain("stone");
|
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 { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
||||||
import { cellKey, edgeKey, stepTarget } from "../src/board";
|
import { cellKey, edgeKey, stepTarget } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
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", () => {
|
describe("expansion combat cards", () => {
|
||||||
it("power attack burns life for extra damage", () => {
|
it("power attack burns life for extra damage", () => {
|
||||||
@@ -73,8 +73,8 @@ describe("expansion combat cards", () => {
|
|||||||
.toEqual(aCards.filter((id) => id !== ms.instanceId));
|
.toEqual(aCards.filter((id) => id !== ms.instanceId));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("butt-head rams for the distance charged", () => {
|
it("butt-head rams for the distance charged (legacy rev 9 rules)", () => {
|
||||||
let { state } = newGame();
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 9 });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const attacker = activePlayer(state);
|
const attacker = activePlayer(state);
|
||||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
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" });
|
state = must(state, me.id, { type: "pickUpTreasure" });
|
||||||
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
|
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
|
||||||
state = must(state, enemy.id, { type: "wardChoice", play: true });
|
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 === me.id)!.life).toBe(12);
|
||||||
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
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", () => {
|
it("idiot forbids item handling and punches but allows counteractions", () => {
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
@@ -417,7 +446,11 @@ describe("strength tears treasures from wizards' arms", () => {
|
|||||||
let torn = 0, kept = 0;
|
let torn = 0, kept = 0;
|
||||||
for (let seed = 1; seed <= 12; seed++) {
|
for (let seed = 1; seed <= 12; seed++) {
|
||||||
const { state, attacker, defender, treasure } = grip(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);
|
if (!r.ok) throw new Error(r.error);
|
||||||
expect(r.state.turn.attackUsed).toBe(true);
|
expect(r.state.turn.attackUsed).toBe(true);
|
||||||
// FAQ: tearing is not picking up — the turn's actions continue.
|
// 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", {
|
state = must(state, "alice", {
|
||||||
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
|
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);
|
if (!r.ok) throw new Error(r.error);
|
||||||
const after = r.state.treasures.find((t) => t.id === caras.id)!;
|
const after = r.state.treasures.find((t) => t.id === caras.id)!;
|
||||||
if (after.carriedBy === "bob") continue; // Bob rolled his 1 — try again
|
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);
|
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 { describe, expect, it } from "vitest";
|
||||||
import { giveCard } from "./helpers";
|
import { drain, giveCard } from "./helpers";
|
||||||
import { edgeKey } from "../src/board";
|
import { edgeKey } from "../src/board";
|
||||||
import {
|
import {
|
||||||
applyCommand,
|
applyCommand,
|
||||||
@@ -290,7 +290,9 @@ describe("the Ward is played in the moment", () => {
|
|||||||
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
|
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
|
||||||
const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
|
const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
|
||||||
if (!r.ok) throw new Error(r.error);
|
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();
|
||||||
|
state = drain(r.state);
|
||||||
expect(state.wardPending).toBeNull();
|
expect(state.wardPending).toBeNull();
|
||||||
expect(state.players.find((p) => p.id === "thief")!.life).toBe(12);
|
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);
|
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;
|
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 {
|
export function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
||||||
const p = state.players.find((p) => p.id === playerId)!;
|
const p = state.players.find((p) => p.id === playerId)!;
|
||||||
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
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 { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
||||||
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
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", () => {
|
describe("around the corner", () => {
|
||||||
it("bends line of sight past a wall that blocks a straight cast", () => {
|
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" });
|
state = must(state, defender.id, { type: "pass" });
|
||||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10);
|
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", () => {
|
describe("blind", () => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
|
import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
|
||||||
import type { CardInstance } from "../src/cards";
|
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. */
|
/** Display a stone for a player during their turn. */
|
||||||
function displayStone(state: GameState, playerId: PlayerId, stoneId: string, slot = 0): GameState {
|
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);
|
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", () => {
|
it("bloodstone cancels slow death's per-draw point", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
|
|||||||
@@ -147,19 +147,26 @@ function shareData(id: string): ShareData | null {
|
|||||||
const escapeHtml = (t: string) =>
|
const escapeHtml = (t: string) =>
|
||||||
t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
|
||||||
/** index.html with this share's OpenGraph card folded into its head —
|
/** index.html with a bespoke OpenGraph card folded into its head —
|
||||||
* crawlers never run the app, so the unfurl must arrive pre-baked. */
|
* crawlers never run the app, so the unfurl must arrive pre-baked. The
|
||||||
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
* stock page carries its own generic card; strip it, or crawlers (which
|
||||||
// Host and proto arrive from request headers — attacker-writable text
|
* take the FIRST tag they meet) never see this page's. */
|
||||||
// that must never reach an HTML attribute raw.
|
function ogPage(metas: string[]): string {
|
||||||
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
return readFileSync(join(staticRoot!, "index.html"), "utf8")
|
||||||
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")
|
|
||||||
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
||||||
.replace(/<title>[^<]*<\/title>\s*/, "");
|
.replace(/<title>[^<]*<\/title>\s*/, "")
|
||||||
const base = `${proto}://${host}`;
|
.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
|
const title = data.whole
|
||||||
? "The whole tale — a game of Wiz-War, replayed"
|
? "The whole tale — a game of Wiz-War, replayed"
|
||||||
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
: `${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 property="og:image:height" content="630"/>`,
|
||||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||||
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
|
`<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) => {
|
const httpServer = createServer((req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -233,6 +268,19 @@ const httpServer = createServer((req, res) => {
|
|||||||
res.end(shareHtml(watch[1]!, data, hostname, proto));
|
res.end(shareHtml(watch[1]!, data, hostname, proto));
|
||||||
return;
|
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));
|
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
|
||||||
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
|
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
|
||||||
res.writeHead(403).end();
|
res.writeHead(403).end();
|
||||||
@@ -475,6 +523,12 @@ wss.on("connection", (socket) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
switch (msg.type) {
|
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": {
|
case "create": {
|
||||||
const name = cleanName(msg.name);
|
const name = cleanName(msg.name);
|
||||||
if (!name) return send(socket, { type: "error", message: "name required" });
|
if (!name) return send(socket, { type: "error", message: "name required" });
|
||||||
|
|||||||
+232
-50
@@ -14,7 +14,7 @@
|
|||||||
import { prefs, savePrefs } from "./prefs.svelte";
|
import { prefs, savePrefs } from "./prefs.svelte";
|
||||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||||
import { local } from "./local.svelte";
|
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";
|
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
net.connect();
|
net.connect();
|
||||||
@@ -23,6 +23,33 @@
|
|||||||
let name = $state(prefs.wizardName);
|
let name = $state(prefs.wizardName);
|
||||||
let joinCode = $state("");
|
let joinCode = $state("");
|
||||||
let claimPhrase = $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);
|
let showHelp = $state(false);
|
||||||
/** The finished game whose victory fanfare has been dismissed. */
|
/** The finished game whose victory fanfare has been dismissed. */
|
||||||
let victorySeen = $state(false);
|
let victorySeen = $state(false);
|
||||||
@@ -73,6 +100,45 @@
|
|||||||
if (!prefs.cautions || reasons.length === 0) go();
|
if (!prefs.cautions || reasons.length === 0) go();
|
||||||
else caution = { reasons, 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,
|
/** A move with its regret checks: stepping off your home still laden,
|
||||||
* or stepping onto ground that bites. */
|
* or stepping onto ground that bites. */
|
||||||
function tryMove(direction: Side, over?: boolean) {
|
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. */
|
/** Suggest-as-you-type pool for the card-naming field, tuned per card. */
|
||||||
const nameSuggestions = $derived.by(() => {
|
const nameSuggestions = $derived.by(() => {
|
||||||
if (!selectedCard) return [];
|
if (!selectedCard) return [];
|
||||||
@@ -542,6 +618,12 @@
|
|||||||
"shieldstone", "soulstone", "speedstone", "visionstone",
|
"shieldstone", "soulstone", "speedstone", "visionstone",
|
||||||
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
|
"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() {
|
function playNumberForMovement() {
|
||||||
if (!selectedCard || !view) return;
|
if (!selectedCard || !view) return;
|
||||||
@@ -622,11 +704,11 @@
|
|||||||
if (!yourMoment) { peekAt(cell); return; }
|
if (!yourMoment) { peekAt(cell); return; }
|
||||||
if (pendingCellFor && selectedCard) {
|
if (pendingCellFor && selectedCard) {
|
||||||
// Stage 2 of teleport-opponent: destination chosen.
|
// Stage 2 of teleport-opponent: destination chosen.
|
||||||
dispatch({
|
dispatch(withMods({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "player", playerId: pendingCellFor },
|
target: { kind: "player", playerId: pendingCellFor },
|
||||||
params: { cell },
|
params: { cell },
|
||||||
});
|
}));
|
||||||
clearSelection();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -653,17 +735,17 @@
|
|||||||
? trapCells.filter((_, i) => i !== already)
|
? trapCells.filter((_, i) => i !== already)
|
||||||
: [...trapCells, cell];
|
: [...trapCells, cell];
|
||||||
if (trapCells.length === 4) {
|
if (trapCells.length === 4) {
|
||||||
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } }));
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
||||||
if (!tradeFrom) { tradeFrom = cell; return; }
|
if (!tradeFrom) { tradeFrom = cell; return; }
|
||||||
dispatch({
|
dispatch(withMods({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
||||||
});
|
}));
|
||||||
clearSelection();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -676,11 +758,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
||||||
dispatch({
|
dispatch(withMods({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "cell", cell },
|
target: { kind: "cell", cell },
|
||||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
}));
|
||||||
});
|
|
||||||
clearSelection();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -858,14 +939,13 @@
|
|||||||
if (!selectedCard || !edgeSelectMode) return;
|
if (!selectedCard || !edgeSelectMode) return;
|
||||||
// The attached number rides along for every edge cast: wand charges,
|
// The attached number rides along for every edge cast: wand charges,
|
||||||
// wall-of-fire durations, wall attacks alike.
|
// wall-of-fire durations, wall attacks alike.
|
||||||
dispatch({
|
dispatch(withMods({
|
||||||
type: "cast",
|
type: "cast",
|
||||||
instanceId: selectedCard.instanceId,
|
instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "edge", cell, side },
|
target: { kind: "edge", cell, side },
|
||||||
...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key")
|
...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key")
|
||||||
? { params: { hold: true } } : {}),
|
? { params: { hold: true } } : {}),
|
||||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
}));
|
||||||
});
|
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,12 +961,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
|
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
|
||||||
dispatch({
|
dispatch(withMods({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "creature", creatureId },
|
target: { kind: "creature", creatureId },
|
||||||
...(selectedCard.cardId === "mega-monster" ? { params: { boost: megaBoost } } : {}),
|
...(selectedCard.cardId === "mega-monster" ? { params: { boost: megaBoost } } : {}),
|
||||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
}));
|
||||||
});
|
|
||||||
clearSelection();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -942,13 +1021,12 @@
|
|||||||
swapMeetTarget = playerId;
|
swapMeetTarget = playerId;
|
||||||
return; // next: pick your item, then theirs
|
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",
|
type: "cast",
|
||||||
instanceId: selectedCard.instanceId,
|
instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "player", playerId },
|
target: { kind: "player", playerId },
|
||||||
};
|
};
|
||||||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
withMods(cmd);
|
||||||
applyMods(cmd);
|
|
||||||
if (selectedCard.cardId === "waterbolt") {
|
if (selectedCard.cardId === "waterbolt") {
|
||||||
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
|
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1084,13 @@
|
|||||||
here !== cellKey(me.home)) {
|
here !== cellKey(me.home)) {
|
||||||
reasons.push(`a treasure lies at your feet, un-grabbed`);
|
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.
|
// The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves.
|
||||||
const drawRoom = Math.min(2, room);
|
const drawRoom = Math.min(2, room);
|
||||||
if (drawCount < drawRoom) {
|
if (drawCount < drawRoom) {
|
||||||
@@ -1172,8 +1257,9 @@
|
|||||||
if (!view || !selectedDef || !yourMoment) return null;
|
if (!view || !selectedDef || !yourMoment) return null;
|
||||||
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
|
const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
|
||||||
if (CELL_CARDS.has(selectedCard!.cardId)) {
|
if (CELL_CARDS.has(selectedCard!.cardId)) {
|
||||||
return eligibleCellsFor(view, selectedCard!.cardId);
|
return eligibleCellsFor(view, selectedCard!.cardId, bent);
|
||||||
}
|
}
|
||||||
if (selectedDef.adjacent === true) {
|
if (selectedDef.adjacent === true) {
|
||||||
const { x, y } = me.position;
|
const { x, y } = me.position;
|
||||||
@@ -1184,19 +1270,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (selectedDef.los !== true) return null;
|
if (selectedDef.los !== true) return null;
|
||||||
const sighted = sightedCellsFor(view);
|
return bent ? bentSightedCellsFor(view) : 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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
|
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
|
||||||
@@ -1423,6 +1497,11 @@
|
|||||||
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
|
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
|
||||||
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
|
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
|
||||||
</label>
|
</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">
|
<label class="pref-row pref-check">
|
||||||
<input type="checkbox" checked={prefs.cautions}
|
<input type="checkbox" checked={prefs.cautions}
|
||||||
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
||||||
@@ -1826,7 +1905,11 @@
|
|||||||
<section class="boxlid">
|
<section class="boxlid">
|
||||||
<div class="boxlid-inner">
|
<div class="boxlid-inner">
|
||||||
<div class="boxlid-title small">Room {net.roomId}</div>
|
<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">
|
<ul class="roster">
|
||||||
{#each net.players as p (p)}
|
{#each net.players as p (p)}
|
||||||
{@const chosen = net.roomColors[p]}
|
{@const chosen = net.roomColors[p]}
|
||||||
@@ -1846,6 +1929,15 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{#if net.spectating}
|
{#if net.spectating}
|
||||||
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
|
<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}
|
{:else}
|
||||||
<div class="standee-row" role="group" aria-label="choose your wizard">
|
<div class="standee-row" role="group" aria-label="choose your wizard">
|
||||||
{#each [0, 1, 2, 3, 4, 5] as c (c)}
|
{#each [0, 1, 2, 3, 4, 5] as c (c)}
|
||||||
@@ -1899,7 +1991,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if view}
|
{:else if view}
|
||||||
<div class="game">
|
<div class="game" class:hand-left={prefs.handLeft}>
|
||||||
<section class="board-zone">
|
<section class="board-zone">
|
||||||
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
||||||
{#if prefs.liveFp && view.you && !net.spectating}
|
{#if prefs.liveFp && view.you && !net.spectating}
|
||||||
@@ -1914,8 +2006,11 @@
|
|||||||
onbody={(id) => (fpvBody = id)}
|
onbody={(id) => (fpvBody = id)}
|
||||||
onWarpStep={(creatureId) => dispatch(creatureId
|
onWarpStep={(creatureId) => dispatch(creatureId
|
||||||
? { type: "creatureWarpStep", creatureId }
|
? { type: "creatureWarpStep", creatureId }
|
||||||
: { type: "warpStep" })} />
|
: { type: "warpStep" })}
|
||||||
|
edgeSelect={edgeSelectMode && yourMoment} />
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="board-frame">
|
||||||
|
<div class="board-viewport" bind:this={boardViewportEl} style:--zoom={boardZoom}>
|
||||||
<Board
|
<Board
|
||||||
{view}
|
{view}
|
||||||
edgeSelectMode={edgeSelectMode && yourMoment}
|
edgeSelectMode={edgeSelectMode && yourMoment}
|
||||||
@@ -1940,6 +2035,12 @@
|
|||||||
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<aside class="paper-rail">
|
<aside class="paper-rail">
|
||||||
@@ -2153,7 +2254,6 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-edge">
|
<div class="table-edge">
|
||||||
{#if ambushVia}
|
{#if ambushVia}
|
||||||
@@ -2199,7 +2299,8 @@
|
|||||||
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}`
|
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}`
|
||||||
: view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId
|
: view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId
|
||||||
? `an illusionary ${cardDef(view.stack.params.cardId).name}`
|
? `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
|
tap a counteraction card, or
|
||||||
{:else}
|
{:else}
|
||||||
{view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
|
{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>
|
<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>
|
<button class="stamp tiny" onclick={castPowerRun}>Run!</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)}
|
{#if selectedCard && confirmCastable(selectedCard.cardId)}
|
||||||
<button class="stamp tiny" onclick={castSelfWithNumber}>
|
<button class="stamp tiny" onclick={castSelfWithNumber}>
|
||||||
Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
|
Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
|
||||||
</button>
|
</button>
|
||||||
@@ -2461,6 +2562,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -2713,6 +2815,31 @@
|
|||||||
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
|
.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; }
|
.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; }
|
.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 {
|
.transfer-slip {
|
||||||
max-width: 30rem;
|
max-width: 30rem;
|
||||||
@@ -2914,14 +3041,41 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 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(9.6rem, 10.4rem) 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;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
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; }
|
||||||
|
}
|
||||||
/* The first-person pane wears exactly the board's width, right above
|
/* 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
|
* 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
|
* keeps the pane out of the intrinsic sizing so the BOARD alone
|
||||||
* decides how wide the column is. */
|
* decides how wide the column is. */
|
||||||
.table-stack {
|
.table-stack {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -2930,19 +3084,46 @@
|
|||||||
/* FPV play: the 3D view is the primary window into the game; the
|
/* FPV play: the 3D view is the primary window into the game; the
|
||||||
* top-down board shrinks to a keymap beneath it. */
|
* top-down board shrinks to a keymap beneath it. */
|
||||||
.table-stack.fpv-primary {
|
.table-stack.fpv-primary {
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 64rem;
|
max-width: 64rem;
|
||||||
}
|
}
|
||||||
.board-zone .table-stack.fpv-primary :global(svg.board) {
|
.board-frame { position: relative; min-width: 0; }
|
||||||
max-height: 30dvh;
|
/* The board's window: it owns the fitted size, the svg wears an
|
||||||
align-self: center;
|
* 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) {
|
.fpv-primary .board-viewport { height: 30dvh; }
|
||||||
max-height: calc(100dvh - 21.5rem);
|
.board-viewport :global(svg.board) {
|
||||||
width: auto;
|
width: calc(var(--fit-w, 100%) * var(--zoom, 1));
|
||||||
max-width: 100%;
|
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; }
|
.paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
|
||||||
|
|
||||||
.slip {
|
.slip {
|
||||||
@@ -3070,6 +3251,7 @@
|
|||||||
.moment-eye:hover { opacity: 1; }
|
.moment-eye:hover { opacity: 1; }
|
||||||
|
|
||||||
.table-edge {
|
.table-edge {
|
||||||
|
grid-column: 1 / -1;
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3426,8 +3608,8 @@
|
|||||||
.board-zone { order: 1; }
|
.board-zone { order: 1; }
|
||||||
.table-edge { order: 2; margin-top: 0.6rem; }
|
.table-edge { order: 2; margin-top: 0.6rem; }
|
||||||
.paper-rail { order: 3; margin-top: 0.9rem; }
|
.paper-rail { order: 3; margin-top: 0.9rem; }
|
||||||
.board-zone :global(svg.board) {
|
.board-viewport {
|
||||||
max-height: 56dvh;
|
height: 56dvh;
|
||||||
}
|
}
|
||||||
.chronicle { max-height: 30dvh; flex: initial; }
|
.chronicle { max-height: 30dvh; flex: initial; }
|
||||||
.masthead { gap: 0.45rem; padding-top: 0.5rem; }
|
.masthead { gap: 0.45rem; padding-top: 0.5rem; }
|
||||||
|
|||||||
@@ -75,9 +75,17 @@
|
|||||||
effects?: BoardFx[] | null;
|
effects?: BoardFx[] | null;
|
||||||
/** The sight line an attack in progress traveled — proof against "how
|
/** The sight line an attack in progress traveled — proof against "how
|
||||||
* can he even see me?", drawn leg by leg through any warp mouth. */
|
* can he even see me?", drawn leg by leg through any warp mouth. */
|
||||||
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; 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();
|
} = $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;
|
const SECTOR = 5;
|
||||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||||
@@ -701,6 +709,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||||
<g class="fx-layer" aria-hidden="true">
|
<g class="fx-layer" aria-hidden="true">
|
||||||
@@ -804,6 +817,13 @@
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
animation: sight-pulse 1.6s ease-in-out infinite;
|
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 {
|
@keyframes sight-pulse {
|
||||||
0%, 100% { opacity: 0.9; }
|
0%, 100% { opacity: 0.9; }
|
||||||
50% { opacity: 0.35; }
|
50% { opacity: 0.35; }
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
|
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
|
||||||
import { deepestFacing } from "./fpv/director";
|
import { deepestFacing } from "./fpv/director";
|
||||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } 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";
|
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;
|
view: GameView;
|
||||||
/** The latest live event batch, numbered so each plays once, with
|
/** The latest live event batch, numbered so each plays once, with
|
||||||
* the view the server sent alongside it. */
|
* the view the server sent alongside it. */
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
onfacing?: ((side: Side) => void) | null;
|
onfacing?: ((side: Side) => void) | null;
|
||||||
/** Cell-card eligibility, shared with the board's dimming aid. */
|
/** Cell-card eligibility, shared with the board's dimming aid. */
|
||||||
litCells?: Set<string> | null;
|
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
|
/** The at-your-feet tray's grab: the pane cannot look down, so what
|
||||||
* lies in your own square shows as clickable icons instead. */
|
* lies in your own square shows as clickable icons instead. */
|
||||||
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
|
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
|
||||||
@@ -222,25 +224,33 @@
|
|||||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||||
|
|
||||||
// --- The fx: each batch plays once, on its own beat. -----------------
|
// --- 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;
|
let playedBatch = 0;
|
||||||
|
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const b = batch;
|
const b = batch;
|
||||||
if (!b || b.n === playedBatch || !me) return;
|
if (!b) return;
|
||||||
playedBatch = b.n;
|
untrack(() => {
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
if (b.n === playedBatch || !me) return;
|
||||||
const started: number[] = [];
|
playedBatch = b.n;
|
||||||
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||||||
timers.push(setTimeout(() => {
|
const starter = setTimeout(() => {
|
||||||
started.push(fx.id);
|
fxTimers.delete(starter);
|
||||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
const ender = setTimeout(() => {
|
||||||
}, delay));
|
fxTimers.delete(ender);
|
||||||
}
|
fpFx = fpFx.filter((f) => f.id !== fx.id);
|
||||||
return () => {
|
}, fx.dur + 80);
|
||||||
timers.forEach(clearTimeout);
|
fxTimers.add(ender);
|
||||||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
}, delay);
|
||||||
};
|
fxTimers.add(starter);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
onDestroy(() => fxTimers.forEach(clearTimeout));
|
||||||
|
|
||||||
// --- Other bodies glide between views. -------------------------------
|
// --- Other bodies glide between views. -------------------------------
|
||||||
let prevView: GameView | null = null;
|
let prevView: GameView | null = null;
|
||||||
@@ -344,12 +354,17 @@
|
|||||||
// plays only for things done TO you, never for your own hands —
|
// plays only for things done TO you, never for your own hands —
|
||||||
// dropping a dagger must not fling your eyes out of your skull.
|
// dropping a dagger must not fling your eyes out of your skull.
|
||||||
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
|
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 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 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);
|
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
|
||||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
targetFacing = wantFacing;
|
||||||
aimed = true;
|
aimed = true;
|
||||||
} else if (performance.now() >= manualUntil) {
|
} else if (performance.now() >= manualUntil) {
|
||||||
// Another wizard acting in your sight line turns your head.
|
// Another wizard acting in your sight line turns your head.
|
||||||
@@ -455,7 +470,7 @@
|
|||||||
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
|
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
|
||||||
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||||||
fx={fpFx} posOverride={actorPos}
|
fx={fpFx} posOverride={actorPos}
|
||||||
ontarget={handleTarget} {litCells} />
|
ontarget={handleTarget} {litCells} {edgeSelect} />
|
||||||
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="bezel-bottom">
|
<div class="bezel-bottom">
|
||||||
@@ -511,7 +526,6 @@
|
|||||||
}
|
}
|
||||||
.bezel-top { border-bottom: 1px solid #2a2620; }
|
.bezel-top { border-bottom: 1px solid #2a2620; }
|
||||||
.bezel-bottom { border-top: 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-zone { flex: 1; display: flex; align-items: center; gap: 8px; }
|
||||||
.bezel-center { justify-content: center; }
|
.bezel-center { justify-content: center; }
|
||||||
.bezel-right { justify-content: flex-end; }
|
.bezel-right { justify-content: flex-end; }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
rubble = [],
|
rubble = [],
|
||||||
ontarget,
|
ontarget,
|
||||||
litCells = null,
|
litCells = null,
|
||||||
|
edgeSelect = false,
|
||||||
}: {
|
}: {
|
||||||
view: GameView;
|
view: GameView;
|
||||||
povId: string;
|
povId: string;
|
||||||
@@ -45,6 +46,9 @@
|
|||||||
/** Squares a selected cell-target card may aim at: the pane dims the
|
/** Squares a selected cell-target card may aim at: the pane dims the
|
||||||
* ineligible ground exactly as the board dims its squares. */
|
* ineligible ground exactly as the board dims its squares. */
|
||||||
litCells?: Set<string> | null;
|
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();
|
} = $props();
|
||||||
|
|
||||||
const FOV = Math.PI / 2.9;
|
const FOV = Math.PI / 2.9;
|
||||||
@@ -386,6 +390,12 @@
|
|||||||
ctx.fillStyle = `rgba(120,70,220,${haze})`;
|
ctx.fillStyle = `rgba(120,70,220,${haze})`;
|
||||||
ctx.fillRect(col, top, 1, wallH);
|
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.
|
// 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 — it tumbles to the floor!`
|
||||||
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
|
: `${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 "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"
|
case "trapSprung": return e.cardId === "gift-from-below"
|
||||||
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
|
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
|
||||||
: `${e.player} walked into an old TRAP! Lose a turn.`;
|
: `${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 "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
|
||||||
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
|
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
|
||||||
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
|
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 "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
|
||||||
case "safeCreated": return `A massive safe slams down around the loot.`;
|
case "safeCreated": return `A massive safe slams down around the loot.`;
|
||||||
case "safeOpened": return null;
|
case "safeOpened": return null;
|
||||||
@@ -275,6 +278,11 @@ function saveSeats(seats: Seat[]): void {
|
|||||||
|
|
||||||
class Net {
|
class Net {
|
||||||
status = $state<"disconnected" | "connected">("disconnected");
|
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);
|
roomId = $state<string | null>(null);
|
||||||
players = $state<string[]>([]);
|
players = $state<string[]>([]);
|
||||||
hostId = $state<string | null>(null);
|
hostId = $state<string | null>(null);
|
||||||
@@ -337,6 +345,14 @@ class Net {
|
|||||||
if (this.ws) return;
|
if (this.ws) return;
|
||||||
const ws = new WebSocket(SERVER_URL);
|
const ws = new WebSocket(SERVER_URL);
|
||||||
this.ws = ws;
|
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 = () => {
|
ws.onopen = () => {
|
||||||
this.watching = null; // the first state after (re)connecting may carry a gap
|
this.watching = null; // the first state after (re)connecting may carry a gap
|
||||||
this.status = "connected";
|
this.status = "connected";
|
||||||
@@ -365,7 +381,10 @@ class Net {
|
|||||||
};
|
};
|
||||||
ws.onmessage = (raw) => {
|
ws.onmessage = (raw) => {
|
||||||
const msg = JSON.parse(raw.data as string);
|
const msg = JSON.parse(raw.data as string);
|
||||||
|
this.lastHeard = Date.now();
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
|
case "pong":
|
||||||
|
break;
|
||||||
case "seat": {
|
case "seat": {
|
||||||
this.token = msg.token;
|
this.token = msg.token;
|
||||||
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface Prefs {
|
|||||||
instantReplay: boolean;
|
instantReplay: boolean;
|
||||||
/** The live first-person pane above the board during play. */
|
/** The live first-person pane above the board during play. */
|
||||||
liveFp: boolean;
|
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";
|
const KEY = "wizwar-prefs";
|
||||||
@@ -29,7 +32,7 @@ const KEY = "wizwar-prefs";
|
|||||||
function load(): Prefs {
|
function load(): Prefs {
|
||||||
const fallback: Prefs = {
|
const fallback: Prefs = {
|
||||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
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 {
|
try {
|
||||||
const raw = localStorage.getItem(KEY);
|
const raw = localStorage.getItem(KEY);
|
||||||
@@ -45,6 +48,7 @@ function load(): Prefs {
|
|||||||
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
||||||
instantReplay: p.instantReplay !== false,
|
instantReplay: p.instantReplay !== false,
|
||||||
liveFp: p.liveFp === true,
|
liveFp: p.liveFp === true,
|
||||||
|
handLeft: p.handLeft === true,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
Reference in New Issue
Block a user