Five fidelity gaps closed: teleport escapes, slime traps, the Big Man
moves like a giant, and the boards are diagram-verified TELEPORT as a counteraction (official FAQ: "the attack has no chance of hitting you"): the defender names an escape square within four spaces, the escape resolves before anything lands, and an ANTI-ANTI pins their boots to the floor. Additive — no revision gate needed. FILL SQUARE WITH SLIME holds spells now: an attack cast at the slime sticks in the gel (leaving the discard pile), springs once at whoever is inside or next enters, and counteractions against the trapped blast cannot touch its caster — reflections vanish into the ooze. A five-point waterbolt washes the slime and its cargo away, and the waterwall waves clear slime from their path. BIG MAN, under rules rev 5, finally moves like the card says: he pushes players and monsters down the corridor ahead of him (stuck or unpushable occupants block his advance), steps over a pit, tacks, or killer ooze for two movement points without ever entering the square (click two cells beyond the hazard), and monsters may not enter his square. All gated so stored games replay under their own rules. The boards were never wrong — the rulebook's Set-Up Diagram photo confirms every pairing the code already had: 2p crossed, 3p stair with the Aisle Warp arc, 4p/6p straight-across, 5p plus with all four corner arcs. The stale TODOs are gone, replaced by tests pinning each letter pair, and relocation's "only opposite board edges connect" (which discards the aisle warp) is pinned too. Wall of Fire vs Waterbolt turned out to be implemented and tested all along — its TODO comment was the only thing wrong. Also: hotseat saves now request durable storage (iOS evicts unprotected origins under pressure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
28bc91c3ac
commit
ed9585aa59
@@ -190,7 +190,7 @@ export function assembleBoard(
|
||||
// Wraparound warps. An opening that faces another sector is a corridor, not
|
||||
// a warp. The remaining outer-boundary openings pair per options.warpPairs
|
||||
// (from the setup diagram's letters) or, by default, straight across the
|
||||
// map. (L-shaped 3p aisle-warp corners are TODO.)
|
||||
// map — including the aisle-warp corner arcs, which are just warp pairs.
|
||||
const warps: Warp[] = [];
|
||||
const inMap = (c: Cell) => cells[cellKey(c)] === true;
|
||||
const openingCell = (sectorIndex: number, side: Side): Cell => {
|
||||
|
||||
+198
-13
@@ -178,7 +178,7 @@ export interface CastStack {
|
||||
powerAttackPoints: number;
|
||||
params: CastParams | null;
|
||||
kind: "spell" | "physical";
|
||||
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[];
|
||||
counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell }[];
|
||||
waitingOn: PlayerId;
|
||||
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
||||
defenderShielded?: boolean;
|
||||
@@ -186,6 +186,8 @@ export interface CastStack {
|
||||
creatureId?: string;
|
||||
/** The wraith's touch also steals a random card if damage lands. */
|
||||
creatureTouch?: "wraith" | "claw";
|
||||
/** A spell freed from slime: counteractions cannot touch its caster. */
|
||||
trapped?: boolean;
|
||||
}
|
||||
|
||||
export interface CastParams {
|
||||
@@ -212,7 +214,9 @@ export interface GameConfig {
|
||||
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
|
||||
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
|
||||
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
|
||||
* creature blows open a counteraction window like any attack.
|
||||
* creature blows open a counteraction window like any attack. Rev 5:
|
||||
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
|
||||
* and bars monsters from his square.
|
||||
*/
|
||||
deckRev?: number;
|
||||
}
|
||||
@@ -225,6 +229,8 @@ export interface GameState {
|
||||
edgeOverrides: Record<string, EdgeState>;
|
||||
/** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */
|
||||
wallDamage: Record<string, number>;
|
||||
/** Spells stuck in slime, waiting for the next visitor (by cell key). */
|
||||
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
|
||||
/** Players whose WARD is set to spring (rules rev 3+; their secret). */
|
||||
wardArmed: PlayerId[];
|
||||
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
|
||||
@@ -536,6 +542,10 @@ export type GameEvent =
|
||||
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
|
||||
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
|
||||
| { type: "chaosShielded"; player: PlayerId }
|
||||
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
|
||||
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
|
||||
| { type: "slimeWashed"; cell: Cell }
|
||||
| { type: "pushed"; by: PlayerId; player?: PlayerId; creatureId?: string; from: Cell; to: Cell }
|
||||
| { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string }
|
||||
| { type: "objectEnchanted"; caster: PlayerId; cardId: string }
|
||||
| { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell }
|
||||
@@ -585,7 +595,7 @@ export type CastTarget =
|
||||
| { kind: "cell"; cell: Cell };
|
||||
|
||||
export type Command =
|
||||
| { type: "move"; direction: Side }
|
||||
| { type: "move"; direction: Side; over?: boolean }
|
||||
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
|
||||
| { type: "punch"; targetId: PlayerId }
|
||||
| { type: "punchWall"; cell: Cell; side: Side }
|
||||
@@ -616,7 +626,7 @@ export type Command =
|
||||
}
|
||||
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] }
|
||||
| { type: "cancelAmbush"; ambushId: string }
|
||||
| { type: "counteract"; instanceId: string }
|
||||
| { type: "counteract"; instanceId: string; params?: { cell?: Cell } }
|
||||
| { type: "pass" }
|
||||
| { type: "pickUpTreasure" }
|
||||
| { type: "pickUpObject"; instanceId: string }
|
||||
@@ -1181,8 +1191,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
},
|
||||
"wall-of-fire": {
|
||||
kind: "neutral",
|
||||
// Neutral use: a burning barrier for [duration] turns. (Counteraction
|
||||
// use vs WATERBOLT: TODO.)
|
||||
// Neutral use: a burning barrier for [duration] turns. (Its counteraction
|
||||
// use against WATERBOLT lives in doCounteract and the resolution pipe.)
|
||||
resolve: (state, events, caster, cmd, magnitude) => {
|
||||
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge";
|
||||
const { cell, side } = cmd.target;
|
||||
@@ -2293,6 +2303,11 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
|
||||
destroyCreature(state, events, c, reason);
|
||||
}
|
||||
}
|
||||
if (state.squareContents[cellKey(probe)]?.kind === "slime") {
|
||||
delete state.squareContents[cellKey(probe)];
|
||||
delete state.slimeTraps[cellKey(probe)];
|
||||
events.push({ type: "slimeWashed", cell: probe });
|
||||
}
|
||||
probe = neighbor(probe, dir);
|
||||
}
|
||||
}
|
||||
@@ -2526,6 +2541,11 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
|
||||
} else {
|
||||
const content = state.squareContents[cellKey(target.to)];
|
||||
if (content?.kind === "stone") return err("that square is solid stone");
|
||||
if ((state.config.deckRev ?? 1) >= 5 &&
|
||||
state.players.some((o) => o.alive && cellKey(o.position) === cellKey(target.to) &&
|
||||
sustainedOn(state, o.id, "big-man").length > 0)) {
|
||||
return err("a giant fills that corridor");
|
||||
}
|
||||
creature.position = target.to;
|
||||
}
|
||||
creature.movementUsed++;
|
||||
@@ -2569,6 +2589,32 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
/** A spell stuck in slime "goes off only once" at whoever is in the gel. */
|
||||
function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerState): void {
|
||||
if (state.stack) return; // one thing at a time; the next visitor springs it
|
||||
const key = cellKey(victim.position);
|
||||
const traps = state.slimeTraps[key];
|
||||
if (!traps || traps.length === 0) return;
|
||||
const trap = traps.shift()!;
|
||||
if (traps.length === 0) delete state.slimeTraps[key];
|
||||
state.discard.push(trap.card);
|
||||
events.push({ type: "slimeTrapSprung", cell: victim.position, cardId: trap.card.cardId, victim: victim.id });
|
||||
state.stack = {
|
||||
attackerId: trap.casterId,
|
||||
defenderId: victim.id,
|
||||
attackCard: trap.card,
|
||||
numberValue: trap.numberValue,
|
||||
amplifyFactor: trap.amplifyFactor,
|
||||
extendFactor: 1,
|
||||
powerAttackPoints: 0,
|
||||
params: null,
|
||||
kind: (CARD_EFFECTS[trap.card.cardId] as AttackEffect | undefined)?.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
waitingOn: victim.id,
|
||||
trapped: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rules rev 4: a creature's blow opens a counteraction window like any attack. */
|
||||
function openCreatureStack(
|
||||
state: GameState,
|
||||
@@ -2719,6 +2765,7 @@ function remapState(
|
||||
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
|
||||
state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey);
|
||||
state.squareContents = remapRecord(state.squareContents, mapCellKey);
|
||||
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
|
||||
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
|
||||
for (const fx of state.sustained) {
|
||||
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
|
||||
@@ -3008,6 +3055,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
||||
board,
|
||||
edgeOverrides: {},
|
||||
wallDamage: {},
|
||||
slimeTraps: {},
|
||||
wardArmed: [],
|
||||
chaosPending: null,
|
||||
doorStates: {},
|
||||
@@ -3082,7 +3130,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
||||
|
||||
if (state.stack) {
|
||||
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
|
||||
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId);
|
||||
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params);
|
||||
if (command.type === "pass") return doPass(state, playerId);
|
||||
return err("an attack is being resolved — counteract or pass");
|
||||
}
|
||||
@@ -3186,7 +3234,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
||||
}
|
||||
|
||||
switch (command.type) {
|
||||
case "move": return doMove(state, command.direction);
|
||||
case "move": return doMove(state, command.direction, command.over === true);
|
||||
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
|
||||
case "punch": return doPunch(state, command.targetId);
|
||||
case "punchWall": return doPunchWall(state, command.cell, command.side);
|
||||
@@ -3242,7 +3290,7 @@ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direct
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
|
||||
@@ -3279,6 +3327,24 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
}
|
||||
|
||||
const view = boardView(state);
|
||||
// BIG MAN: "Using 2 movement points, you can step over a PIT, TACKS, or
|
||||
// KILLER OOZE" (rules rev 5). The giant strides the hazard square without
|
||||
// entering it — one point charged here, then the normal step below lands
|
||||
// him beyond it with every usual arrival effect (and the second point).
|
||||
if (over) {
|
||||
const big = (state.config.deckRev ?? 1) >= 5 && sustainedOn(state, p.id, "big-man").length > 0;
|
||||
if (!big) return err("only a giant steps over hazards");
|
||||
if (isBlinded(state, p)) return err("you cannot leap what you cannot see");
|
||||
if (state.turn.movementAllowance - state.turn.movementUsed < 2) return err("stepping over costs 2 movement");
|
||||
const hop1 = stepTarget(view, p.position, direction);
|
||||
if (hop1.kind !== "step") return err("nothing to step over that way");
|
||||
const hazard = state.squareContents[cellKey(hop1.to)]?.kind;
|
||||
if (hazard !== "pit" && hazard !== "tacks" && hazard !== "ooze") {
|
||||
return err("you may step over a pit, tacks, or killer ooze");
|
||||
}
|
||||
state.turn.movementUsed++;
|
||||
p.position = hop1.to;
|
||||
}
|
||||
const target = stepTarget(view, p.position, direction);
|
||||
const misted = isMisted(state, p.id);
|
||||
|
||||
@@ -3332,6 +3398,41 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
return err("a giant fills that corridor");
|
||||
}
|
||||
}
|
||||
// BIG MAN pushes: "You can push monsters or other players (in an adjacent
|
||||
// square) down the corridor ahead of you as you move" (rules rev 5). No
|
||||
// room to shove them onward means no way forward for the giant either.
|
||||
if ((state.config.deckRev ?? 1) >= 5 && via === "step" &&
|
||||
sustainedOn(state, p.id, "big-man").length > 0) {
|
||||
const here = cellKey(p.position);
|
||||
const pushPlayers = state.players.filter((o) => o.id !== p.id && o.alive && cellKey(o.position) === here);
|
||||
const pushCreatures = state.creatures.filter((c) => cellKey(c.position) === here);
|
||||
if (pushPlayers.length > 0 || pushCreatures.length > 0) {
|
||||
if (pushPlayers.some((o) => isLockedInPlace(state, o.id))) {
|
||||
p.position = from;
|
||||
return err("someone there is stuck fast and cannot be pushed");
|
||||
}
|
||||
const shove = stepTarget(view, p.position, direction);
|
||||
const shoveOk =
|
||||
shove.kind === "step" &&
|
||||
state.squareContents[cellKey(shove.to)]?.kind !== "stone" &&
|
||||
!state.players.some((o) => o.alive && cellKey(o.position) === cellKey(shove.to) &&
|
||||
sustainedOn(state, o.id, "big-man").length > 0);
|
||||
if (!shoveOk) {
|
||||
p.position = from;
|
||||
return err("no room to push them onward");
|
||||
}
|
||||
for (const o of pushPlayers) {
|
||||
const oFrom = o.position;
|
||||
o.position = { ...shove.to };
|
||||
events.push({ type: "pushed", by: p.id, player: o.id, from: oFrom, to: o.position });
|
||||
}
|
||||
for (const c of pushCreatures) {
|
||||
const cFrom = c.position;
|
||||
c.position = { ...shove.to };
|
||||
events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position });
|
||||
}
|
||||
}
|
||||
}
|
||||
// FEAR: no one moves within 3 spaces of the fearsome one.
|
||||
for (const other of state.players) {
|
||||
if (other.id === p.id || !other.alive) continue;
|
||||
@@ -3452,10 +3553,12 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
}
|
||||
checkVictory(state, events);
|
||||
}
|
||||
// FILL SQUARE WITH SLIME: entering ends your turn's actions.
|
||||
// FILL SQUARE WITH SLIME: entering ends your turn's actions — and any
|
||||
// spell stuck in the gel goes off at the visitor.
|
||||
if (content?.kind === "slime" && p.alive && !misted) {
|
||||
events.push({ type: "stuckInSlime", player: p.id, at: p.position });
|
||||
state.turn.actionsEnded = true;
|
||||
springSlimeTrap(state, events, p);
|
||||
}
|
||||
// IDIOT lifts when the victim reaches their own treasure.
|
||||
if (sustainedOn(state, p.id, "idiot").length > 0) {
|
||||
@@ -4005,6 +4108,47 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
|
||||
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
|
||||
// washes the slime away instead.
|
||||
if (cmd.target?.kind === "cell" &&
|
||||
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
|
||||
const cell = cmd.target.cell;
|
||||
if (effect.requiresLos && !casterLos(state, caster, caster.position, cell)) {
|
||||
return err("no line of sight to the slime");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
{
|
||||
const werr = spendWandCharge(state, caster, wandEvents);
|
||||
if (werr) return err(werr);
|
||||
}
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||
state.turn.attackUsed = true;
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events2: GameEvent[] = [...wandEvents];
|
||||
if (inHand.cardId === "waterbolt" && mods.magnitude.power >= 5) {
|
||||
delete state.squareContents[cellKey(cell)];
|
||||
delete state.slimeTraps[cellKey(cell)];
|
||||
events2.push({ type: "slimeWashed", cell });
|
||||
return { ok: true, state, events: events2 };
|
||||
}
|
||||
// The card leaves the discard and lives in the gel.
|
||||
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
|
||||
if (di !== -1) state.discard.splice(di, 1);
|
||||
const key = cellKey(cell);
|
||||
state.slimeTraps[key] = [...(state.slimeTraps[key] ?? []), {
|
||||
card: inHand, casterId: caster.id,
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
amplifyFactor: 2 ** mods.amplifies.length,
|
||||
}];
|
||||
events2.push({ type: "spellTrapped", caster: caster.id, cell, cardId: inHand.cardId });
|
||||
// "affect anyone in the slime": a current occupant springs it at once.
|
||||
const occupant = state.players.find((p) => p.alive && p.id !== caster.id && cellKey(p.position) === key);
|
||||
if (occupant) springSlimeTrap(state, events2, occupant);
|
||||
return { ok: true, state, events: events2 };
|
||||
}
|
||||
|
||||
// "Any attack against an inanimate object counts as your one attack for
|
||||
// the turn." A wall or door soaks the spell's damage; no counteractions.
|
||||
if (cmd.target?.kind === "edge") {
|
||||
@@ -4357,7 +4501,7 @@ function checkAmbushes(
|
||||
}
|
||||
}
|
||||
|
||||
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): CommandResult {
|
||||
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }): CommandResult {
|
||||
const state = clone(prev);
|
||||
const stack = state.stack!;
|
||||
const player = state.players.find((p) => p.id === playerId)!;
|
||||
@@ -4404,6 +4548,31 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
// TELEPORT: "If you use this spell as a Counteraction, the attack has no
|
||||
// chance of hitting you" (official FAQ). The escape happens at resolution,
|
||||
// so an ANTI-ANTI can still pin your boots to the floor.
|
||||
if (card.cardId === "teleport") {
|
||||
const to = params?.cell;
|
||||
if (!to) return err("teleport needs a destination cell");
|
||||
if (isLockedInPlace(state, playerId)) return err("you are locked in place");
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(to)]) return err("destination is off the board");
|
||||
if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone");
|
||||
if (wallIgnoringDistance(view, player.position, to) > 4) {
|
||||
return err("teleport reaches at most four spaces");
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
state.discard.push(card);
|
||||
stack.counters.push({ player: playerId, card, nullified: false, cell: to });
|
||||
stack.waitingOn = stack.attackerId;
|
||||
state.lastSpellUsed[playerId] = card.cardId;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
|
||||
};
|
||||
}
|
||||
|
||||
// WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT."
|
||||
if (card.cardId === "wall-of-fire") {
|
||||
if (stack.attackCard?.cardId !== "waterbolt") {
|
||||
@@ -4582,6 +4751,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
pipe.fullyStopped = true;
|
||||
continue;
|
||||
}
|
||||
if (counter.card.cardId === "teleport") {
|
||||
pipe.damage = 0;
|
||||
pipe.duration = 0;
|
||||
pipe.fullyStopped = true;
|
||||
continue;
|
||||
}
|
||||
if (stack.creatureId &&
|
||||
(counter.card.cardId === "reflection" || counter.card.cardId === "full-reflection")) {
|
||||
// "REFLECTIONs used on the wraith's touch will damage the wraith."
|
||||
@@ -4598,13 +4773,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
if (ce && ce.kind === "counter") ce.apply(pipe);
|
||||
}
|
||||
|
||||
// A surviving teleport counter whisks the defender away before anything lands.
|
||||
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
|
||||
if (escape) {
|
||||
const from = defender.position;
|
||||
defender.position = { ...escape.cell! };
|
||||
events.push({ type: "teleported", player: defender.id, from, to: defender.position, by: defender.id, cardId: "teleport" });
|
||||
}
|
||||
|
||||
let damageDealt = 0;
|
||||
const attackingCreature = stack.creatureId
|
||||
? state.creatures.find((c) => c.id === stack.creatureId)
|
||||
: undefined;
|
||||
if (pipe.redirected) {
|
||||
if (pipe.damage > 0) {
|
||||
if (attackingCreature) {
|
||||
if (stack.trapped) {
|
||||
// "Counteractions against 'trapped' attacks do not affect the caster."
|
||||
} else if (attackingCreature) {
|
||||
damageCreature(state, events, attackingCreature, pipe.damage, "reflected touch");
|
||||
} else {
|
||||
applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id);
|
||||
@@ -4634,7 +4819,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
state.discard.push(card!);
|
||||
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
|
||||
}
|
||||
if (pipe.reflectedDamage > 0) {
|
||||
if (pipe.reflectedDamage > 0 && !stack.trapped) {
|
||||
if (attackingCreature) {
|
||||
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
|
||||
} else {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// shuffled and randomly rotated, then laid out per player count; each player
|
||||
// gets the sector containing their home star.
|
||||
//
|
||||
// Implemented: 2 players (1x2 column, crossed side pairings per the diagram)
|
||||
// and 4 players (2x2 square, straight-across pairings). 3 players (L-shape
|
||||
// with the AUTO WARP corner connector) and 5+ (two sets) are TODO.
|
||||
// All five layouts are verified against the rulebook's Set-Up Diagram
|
||||
// (research/images/6e-rules-setup-diagram.jpg): 2p crossed pairs, 3p stair
|
||||
// with the Aisle Warp arc, 4p and 6p straight-across, 5p plus with corner
|
||||
// warps at every notch. board.test.ts pins each pairing.
|
||||
|
||||
import {
|
||||
assembleBoard,
|
||||
|
||||
@@ -173,3 +173,66 @@ describe("line of sight through wraparound openings", () => {
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, stone)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
import { createRng } from "../src/rng";
|
||||
import { setupBoard as setupBoardForPins } from "../src/setups";
|
||||
|
||||
describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
|
||||
const setupBoard = setupBoardForPins;
|
||||
|
||||
function onEdge(c: { x: number; y: number }, origin: { x: number; y: number }, side: string): boolean {
|
||||
if (side === "N") return c.y === origin.y && c.x >= origin.x && c.x < origin.x + 5;
|
||||
if (side === "S") return c.y === origin.y + 4 && c.x >= origin.x && c.x < origin.x + 5;
|
||||
if (side === "W") return c.x === origin.x && c.y >= origin.y && c.y < origin.y + 5;
|
||||
return c.x === origin.x + 4 && c.y >= origin.y && c.y < origin.y + 5;
|
||||
}
|
||||
function hasWarp(board: { warps: { from: { cell: { x: number; y: number } }; to: { cell: { x: number; y: number } } }[] },
|
||||
a: { o: { x: number; y: number }; s: string }, b: { o: { x: number; y: number }; s: string }): boolean {
|
||||
return board.warps.some((w) => onEdge(w.from.cell, a.o, a.s) && onEdge(w.to.cell, b.o, b.s));
|
||||
}
|
||||
|
||||
it("3 players: the aisle-warp arc plus B, C, A letter pairs", () => {
|
||||
const { board } = setupBoard(3, createRng(7));
|
||||
const top = { x: 5, y: 0 }, bl = { x: 0, y: 5 }, br = { x: 5, y: 5 };
|
||||
expect(hasWarp(board, { o: top, s: "W" }, { o: bl, s: "N" })).toBe(true); // Aisle Warp
|
||||
expect(hasWarp(board, { o: top, s: "N" }, { o: br, s: "S" })).toBe(true); // B
|
||||
expect(hasWarp(board, { o: top, s: "E" }, { o: bl, s: "S" })).toBe(true); // C
|
||||
expect(hasWarp(board, { o: bl, s: "W" }, { o: br, s: "E" })).toBe(true); // A
|
||||
});
|
||||
|
||||
it("5 players: opposite tips wrap, notch corners arc around", () => {
|
||||
const { board } = setupBoard(5, createRng(7));
|
||||
const n = { x: 5, y: 0 }, w = { x: 0, y: 5 }, e = { x: 10, y: 5 }, s = { x: 5, y: 10 };
|
||||
expect(hasWarp(board, { o: n, s: "N" }, { o: s, s: "S" })).toBe(true); // A
|
||||
expect(hasWarp(board, { o: w, s: "W" }, { o: e, s: "E" })).toBe(true); // B
|
||||
expect(hasWarp(board, { o: n, s: "W" }, { o: w, s: "N" })).toBe(true); // corners
|
||||
expect(hasWarp(board, { o: n, s: "E" }, { o: e, s: "N" })).toBe(true);
|
||||
expect(hasWarp(board, { o: s, s: "W" }, { o: w, s: "S" })).toBe(true);
|
||||
expect(hasWarp(board, { o: s, s: "E" }, { o: e, s: "S" })).toBe(true);
|
||||
});
|
||||
|
||||
it("6 players: straight-across wrap on both axes", () => {
|
||||
const { board } = setupBoard(6, createRng(7));
|
||||
for (const w of board.warps) {
|
||||
const colinear = w.from.cell.x === w.to.cell.x || w.from.cell.y === w.to.cell.y;
|
||||
expect(colinear).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("relocation discards the aisle warp: only opposite edges connect after", async () => {
|
||||
const { createGame, applyCommand, activePlayer } = await import("../src/game");
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
|
||||
const bent = (warps: typeof state.board.warps) =>
|
||||
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
|
||||
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
|
||||
const me = activePlayer(state);
|
||||
me.hand[0] = { instanceId: "rel#1", cardId: "relocate-sector" };
|
||||
// Slide the top sector from (5,0) across to (0,0): an L becomes a block.
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: "rel#1",
|
||||
target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(bent(r.state.board.warps).length).toBe(0); // "only opposite board edges connect"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES } from "../src/board";
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
|
||||
@@ -534,3 +534,139 @@ describe("zero-damage utility attacks", () => {
|
||||
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(treasure.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport as a counteraction", () => {
|
||||
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const escape = { x: d.position.x, y: d.position.y + 1 };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||
});
|
||||
|
||||
it("ANTI-ANTI pins the boots: the escape never happens and the spell lands", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const home = { ...d.position };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
|
||||
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(home));
|
||||
expect(after.life).toBe(10);
|
||||
});
|
||||
|
||||
it("reaches at most four spaces", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
const far = { x: d.position.x, y: d.position.y + 5 };
|
||||
expect(applyCommand(state, defender, {
|
||||
type: "counteract", instanceId: tp.instanceId, params: { cell: far },
|
||||
}).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slime holds spells", () => {
|
||||
function slimeRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||
// A slime square beside the victim, with the victim adjacent to walk in.
|
||||
const spot = { x: victim.position.x, y: victim.position.y };
|
||||
const cellBeside = (() => {
|
||||
const view = boardView(state);
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view, spot, side);
|
||||
if (t.kind === "step" && !state.players.some((p) => cellKey(p.position) === cellKey(t.to)) &&
|
||||
!boardView(state).homes.some((h) => cellKey(h) === cellKey(t.to))) {
|
||||
return { cell: t.to, side };
|
||||
}
|
||||
}
|
||||
throw new Error("setup: no free square beside the victim");
|
||||
})();
|
||||
state.squareContents[cellKey(cellBeside.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
|
||||
// The caster stands with the victim: one open edge to the slime = clear LOS.
|
||||
caster.position = { ...victim.position };
|
||||
return { state, caster: caster.id, victim: victim.id, slimeAt: cellBeside };
|
||||
}
|
||||
|
||||
it("a fireball cast at the slime waits, then goes off on the next visitor", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(1);
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
// The victim walks in: stuck, and the fireball erupts.
|
||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||
expect(state.stack?.attackCard?.cardId).toBe("fireball");
|
||||
expect(state.stack?.trapped).toBe(true);
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(10);
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(0);
|
||||
});
|
||||
|
||||
it("reflections off a trapped spell never touch its caster", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = must(state, rig.caster, { type: "pass" });
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
|
||||
});
|
||||
|
||||
it("a five-point waterbolt washes the slime (and its cargo) away", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
const wb = giveCard(state, rig.victim, "waterbolt", "WB", 0);
|
||||
giveCard(state, rig.victim, "number-5", "N", 1);
|
||||
state = must(state, rig.victim, {
|
||||
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-5#N"],
|
||||
target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
expect(state.squareContents[cellKey(rig.slimeAt.cell)]).toBeUndefined();
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,3 +341,102 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe("big man (rules rev 5)", () => {
|
||||
function bigRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
state = toRound2(state);
|
||||
const giant = activePlayer(state);
|
||||
state.sustained.push({
|
||||
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
|
||||
remainingTurns: 9, data: {},
|
||||
});
|
||||
return { state, giant };
|
||||
}
|
||||
|
||||
/** Any square with two open steps in a straight line (test surgery spot). */
|
||||
function straightRun(state: GameState): { at: Cell; side: Side; mid: Cell; far: Cell } {
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const at = { x, y };
|
||||
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||
for (const side of SIDES) {
|
||||
const t1 = stepTarget(view, at, side);
|
||||
if (t1.kind !== "step") continue;
|
||||
const t2 = stepTarget(view, t1.to, side);
|
||||
if (t2.kind !== "step") continue;
|
||||
return { at, side, mid: t1.to, far: t2.to };
|
||||
}
|
||||
}
|
||||
throw new Error("no straight corridor anywhere — impossible");
|
||||
}
|
||||
|
||||
it("pushes a wizard down the corridor as he advances", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
const run = straightRun(state);
|
||||
const giant = state.players.find((p) => p.id === rig.giant.id)!;
|
||||
giant.position = { ...run.at };
|
||||
victim.position = { ...run.mid };
|
||||
state = must(state, rig.giant.id, { type: "move", direction: run.side });
|
||||
const after = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(run.far));
|
||||
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
|
||||
});
|
||||
|
||||
it("no room to shove means no way forward", () => {
|
||||
const rig = bigRig();
|
||||
const state = rig.state;
|
||||
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
const view = boardView(state);
|
||||
// Find a step whose far side is walled: victim there cannot be pushed.
|
||||
for (const side of SIDES) {
|
||||
const t1 = stepTarget(view, rig.giant.position, side);
|
||||
if (t1.kind !== "step") continue;
|
||||
const t2 = stepTarget(view, t1.to, side);
|
||||
if (t2.kind === "step") continue;
|
||||
victim.position = { ...t1.to };
|
||||
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
|
||||
expect(r.ok).toBe(false);
|
||||
return;
|
||||
}
|
||||
// Seed offered no walled pocket beside the home; that is fine — the
|
||||
// pushable case above pins the mechanism.
|
||||
});
|
||||
|
||||
it("monsters cannot enter the giant's square", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const view = boardView(state);
|
||||
const spot = emptyNeighborCell(state, rig.giant.position);
|
||||
state.creatures.push({
|
||||
id: "t1", kind: "troll", controllerId: state.players.find((p) => p.id !== rig.giant.id)!.id,
|
||||
position: { ...spot.cell },
|
||||
damage: 0, maxDamage: 8, movesPerTurn: 3, movementUsed: 0,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
|
||||
});
|
||||
state = must(state, rig.giant.id, { type: "endTurn", draw: 0 });
|
||||
const controller = activePlayer(state).id;
|
||||
const back = (["N", "S", "E", "W"] as const).find((d) => {
|
||||
const t = stepTarget(view, spot.cell, d);
|
||||
return t.kind === "step" && cellKey(t.to) === cellKey(rig.giant.position);
|
||||
})!;
|
||||
const r = applyCommand(state, controller, { type: "moveCreature", creatureId: "t1", direction: back });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("steps over a pit for two movement points, never entering it", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const run = straightRun(state);
|
||||
state.players.find((p) => p.id === rig.giant.id)!.position = { ...run.at };
|
||||
state.squareContents[cellKey(run.mid)] = { kind: "pit", damage: 0, createdBy: rig.giant.id };
|
||||
state = must(state, rig.giant.id, { type: "move", direction: run.side, over: true });
|
||||
const g = state.players.find((p) => p.id === rig.giant.id)!;
|
||||
expect(cellKey(g.position)).toBe(cellKey(run.far));
|
||||
expect(state.turn.movementUsed).toBe(2);
|
||||
expect(g.inPit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface Room {
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
||||
const RULES_REV = 4;
|
||||
const RULES_REV = 5;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
|
||||
@@ -201,6 +201,10 @@
|
||||
peekCard = null;
|
||||
peekCreatureId = null;
|
||||
if (youMustRespond || youMustShield) {
|
||||
if (youMustRespond && card.cardId === "teleport") {
|
||||
selectedCard = card; // then click the escape square
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "counteract", instanceId: card.instanceId });
|
||||
return;
|
||||
}
|
||||
@@ -317,6 +321,11 @@
|
||||
|
||||
function clickCell(cell: { x: number; y: number }) {
|
||||
if (!view) return;
|
||||
if (youMustRespond && selectedCard?.cardId === "teleport") {
|
||||
dispatch({ type: "counteract", instanceId: selectedCard.instanceId, params: { cell } });
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
if (!isYourTurn) { peekAt(cell); return; }
|
||||
if (pendingCellFor && selectedCard) {
|
||||
// Stage 2 of teleport-opponent: destination chosen.
|
||||
@@ -428,6 +437,21 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
// BIG MAN: clicking two squares away over a pit/tacks/ooze leaps it.
|
||||
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === view!.you)) {
|
||||
for (const side of SIDES) {
|
||||
const mid = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
const far = { x: me.position.x + (side === "E" ? 2 : side === "W" ? -2 : 0),
|
||||
y: me.position.y + (side === "S" ? 2 : side === "N" ? -2 : 0) };
|
||||
const hazard = view.squareContents[cellKey(mid)]?.kind;
|
||||
if (cellKey(far) === cellKey(cell) &&
|
||||
(hazard === "pit" || hazard === "tacks" || hazard === "ooze")) {
|
||||
dispatch({ type: "move", direction: side, over: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The click claimed no action — offer the square's card instead.
|
||||
peekAt(cell);
|
||||
}
|
||||
@@ -1162,7 +1186,9 @@
|
||||
{/if}
|
||||
{#if youMustRespond && view.stack}
|
||||
<span class="hint-alert">
|
||||
{#if view.stack.defenderId === view.you}
|
||||
{#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"}
|
||||
Escaping by teleport — click a square within four spaces.
|
||||
{:else if view.stack.defenderId === view.you}
|
||||
{view.stack.attackerId} attacks with
|
||||
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} —
|
||||
tap a counteraction card, or
|
||||
|
||||
@@ -101,7 +101,7 @@ class LocalGame {
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
...(colors ? { colors } : {}),
|
||||
deckRev: 4,
|
||||
deckRev: 5,
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
this.config = config;
|
||||
@@ -118,6 +118,9 @@ class LocalGame {
|
||||
this.tallyId = crypto.randomUUID();
|
||||
this.activeMs = 0;
|
||||
this.lastMoveAt = Date.now();
|
||||
// iOS evicts unprotected origin storage under pressure; ask for durability
|
||||
// so a saved hotseat game survives more than a forced reload.
|
||||
navigator.storage?.persist?.().catch(() => {});
|
||||
net.reportHotseat({ id: this.tallyId, stage: "started", players: cleaned.length });
|
||||
this.persist();
|
||||
return null;
|
||||
@@ -152,6 +155,7 @@ class LocalGame {
|
||||
this.gameState = current;
|
||||
this.active = true;
|
||||
this.viewerId = null;
|
||||
navigator.storage?.persist?.().catch(() => {});
|
||||
this.handoffTo = actorId(current);
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -145,6 +145,12 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
|
||||
case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
|
||||
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
|
||||
case "pushed": return e.player
|
||||
? `${e.by} shoves ${e.player} down the corridor!`
|
||||
: `${e.by} shoves the beast down the corridor!`;
|
||||
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime — it sticks, waiting.`;
|
||||
case "slimeTrapSprung": return `The slime releases its ${spellName(e.cardId)} at ${e.victim}!`;
|
||||
case "slimeWashed": return `The wave washes the slime away.`;
|
||||
case "wallDamaged": {
|
||||
const what = e.needed === 15 ? "door" : "wall";
|
||||
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name} — ${e.total}/${e.needed}.`;
|
||||
|
||||
Reference in New Issue
Block a user