Credibility pass: session residue swept from the rev-10 run
War-story comments become timeless constraints (carrier rule, boobytrap tell, remove-curse prescription); the rev changelog reunites under GameConfig.deckRev; three hand-copied cardless stack literals fold into openCardlessStack; the visionstone one-edge loop is shared via throughOneEdge with the edge-target hairpin exclusion stated; drain() moves to test helpers and replaces four inline copies; the butt-head sweep test loses its dead scaffolding and its expect(refused || true) tautology and now asserts both branches; a process-named suite is renamed for the behavior it pins; dead .bezel-spacer CSS and the fx effect's fossilized cleanup story go. No behavior changes: 304 engine tests pass, 36/36 ledgers replay, web typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
f2af1e26f8
commit
e93bcc15a2
@@ -1070,9 +1070,8 @@ 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
|
||||||
// The engine wants the whole prescription — patient and ailment both —
|
// whole prescription — patient and ailment both — or it refuses the cast.
|
||||||
// or the cast is refused and the fallback reads as an idle bot.
|
|
||||||
const myCurse = view.sustained.find(
|
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,
|
||||||
);
|
);
|
||||||
@@ -1594,8 +1593,7 @@ export function automatonCommand(
|
|||||||
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);
|
||||||
// A carrier never chases: the delivery in hand outranks the gold in
|
// A carrier never chases: the delivery in hand outranks the gold in
|
||||||
// someone else's. Chasing while laden once marched a bot across the
|
// someone else's.
|
||||||
// whole board, four steps from a winning drop.
|
|
||||||
const chasing = thief !== null && strikeLive && !self.carriedTreasureId;
|
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
|
||||||
|
|||||||
+56
-60
@@ -232,17 +232,7 @@ export interface CastParams {
|
|||||||
hold?: boolean;
|
hold?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
* 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. */
|
|
||||||
export const CURRENT_RULES_REV = 10;
|
export const CURRENT_RULES_REV = 10;
|
||||||
|
|
||||||
export interface GameConfig {
|
export interface GameConfig {
|
||||||
@@ -267,6 +257,19 @@ 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.
|
||||||
*/
|
*/
|
||||||
deckRev?: number;
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
@@ -402,21 +405,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);
|
||||||
@@ -539,15 +546,13 @@ function castSightEdge(
|
|||||||
side: Side,
|
side: Side,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (losToEdge(board, caster.position, cell, side)) return true;
|
if (losToEdge(board, caster.position, cell, side)) return true;
|
||||||
if (displays(caster, "visionstone")) {
|
if (displays(caster, "visionstone") &&
|
||||||
for (const [key, edge] of Object.entries(board.edges)) {
|
throughOneEdge(board, (b) => losToEdge(b, caster.position, cell, side))) {
|
||||||
if (edge === "open") continue;
|
return true;
|
||||||
const edges = { ...board.edges };
|
|
||||||
delete edges[key];
|
|
||||||
if (losToEdge({ ...board, edges }, caster.position, cell, side)) return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!cmd.aroundCornerInstanceId) return false;
|
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)) {
|
for (const key of Object.keys(board.cells)) {
|
||||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||||
const mid = { x: mx, y: my };
|
const mid = { x: mx, y: my };
|
||||||
@@ -2016,7 +2021,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
// The FIRST cell in the command is the real trap — but that truth
|
// The FIRST cell in the command is the real trap — but that truth
|
||||||
// lives only in realKey. Stored and broadcast in canonical order,
|
// lives only in realKey. Stored and broadcast in canonical order,
|
||||||
// the tokens carry no tell: position must never whisper which one
|
// the tokens carry no tell: position must never whisper which one
|
||||||
// bites (the placement order once did, to anyone reading events).
|
// bites.
|
||||||
const laid = [...cells].sort((a, b) => cellKey(a).localeCompare(cellKey(b)));
|
const laid = [...cells].sort((a, b) => cellKey(a).localeCompare(cellKey(b)));
|
||||||
state.boobytraps.push({ casterId: caster.id, cells: laid, realKey: cellKey(cells[0]!) });
|
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: "boobytrapPlaced", caster: caster.id, cells: laid.map((c) => ({ ...c })) });
|
||||||
@@ -3220,21 +3225,34 @@ function openCreatureStack(
|
|||||||
damage: number,
|
damage: number,
|
||||||
touch?: "wraith" | "claw",
|
touch?: "wraith" | "claw",
|
||||||
kind: "physical" | "spell" = "physical",
|
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,
|
|
||||||
counters: [],
|
counters: [],
|
||||||
waitingOn: victim.id,
|
waitingOn: defenderId,
|
||||||
creatureId: creature.id,
|
...opts,
|
||||||
...(touch ? { creatureTouch: touch } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4608,20 +4626,9 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
|||||||
// nothing a counter does can touch the Ward's caster. Older games
|
// nothing a counter does can touch the Ward's caster. Older games
|
||||||
// recorded the instant bite and replay it.
|
// recorded the instant bite and replay it.
|
||||||
if ((state.config.deckRev ?? 1) >= 9) {
|
if ((state.config.deckRev ?? 1) >= 9) {
|
||||||
state.stack = {
|
openCardlessStack(state, owner.id, taker.id, {
|
||||||
attackerId: owner.id,
|
params: { damage: 3 }, kind: "spell", trapped: true,
|
||||||
defenderId: taker.id,
|
});
|
||||||
attackCard: null,
|
|
||||||
numberValue: null,
|
|
||||||
amplifyFactor: 1,
|
|
||||||
extendFactor: 1,
|
|
||||||
powerAttackPoints: 0,
|
|
||||||
params: { damage: 3 },
|
|
||||||
kind: "spell",
|
|
||||||
counters: [],
|
|
||||||
waitingOn: taker.id,
|
|
||||||
trapped: true,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
applyDamage(state, events, taker, 3, "warded treasure", null);
|
applyDamage(state, events, taker, 3, "warded treasure", null);
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
@@ -4746,20 +4753,9 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
|
|||||||
// Rev 6: "this would be an attack" — the grab opens a counteraction
|
// Rev 6: "this would be an attack" — the grab opens a counteraction
|
||||||
// window like any punch; the clutch roll waits for the counters.
|
// window like any punch; the clutch roll waits for the counters.
|
||||||
if ((state.config.deckRev ?? 1) >= 6) {
|
if ((state.config.deckRev ?? 1) >= 6) {
|
||||||
state.stack = {
|
openCardlessStack(state, attacker.id, target.id, {
|
||||||
attackerId: attacker.id,
|
params: null, kind: "physical", tearTreasure: true,
|
||||||
defenderId: target.id,
|
});
|
||||||
attackCard: null,
|
|
||||||
numberValue: null,
|
|
||||||
amplifyFactor: 1,
|
|
||||||
extendFactor: 1,
|
|
||||||
powerAttackPoints: 0,
|
|
||||||
params: null,
|
|
||||||
kind: "physical",
|
|
||||||
counters: [],
|
|
||||||
waitingOn: target.id,
|
|
||||||
tearTreasure: true,
|
|
||||||
};
|
|
||||||
events.push({ type: "treasureTearAttempted", attacker: attacker.id, defender: target.id });
|
events.push({ type: "treasureTearAttempted", attacker: attacker.id, defender: target.id });
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -980,7 +980,7 @@ describe("the denial planner offers only castable blocks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("game-six lessons from the table", () => {
|
describe("interception, escape, and hazard sense", () => {
|
||||||
function toBot(state: GameState): GameState {
|
function toBot(state: GameState): GameState {
|
||||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
|||||||
@@ -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,7 +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 });
|
||||||
while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" });
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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") {
|
||||||
@@ -146,14 +146,6 @@ describe("monsters", () => {
|
|||||||
expect(bitten.hand.length).toBe(6);
|
expect(bitten.hand.length).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Rev 8: scorches open counteraction windows — wave them through. */
|
|
||||||
function drain(state: GameState): GameState {
|
|
||||||
while (state.stack) {
|
|
||||||
state = must(state, state.stack.waitingOn, { type: "pass" });
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("the fire imp scorches on sight and dies only to water", () => {
|
it("the fire imp scorches on sight and dies only to water", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
@@ -824,7 +816,7 @@ describe("the imp's fire is a spell (rev 8)", () => {
|
|||||||
expect(state.stack?.kind).toBe("spell");
|
expect(state.stack?.kind).toBe("spell");
|
||||||
giveCard(state, "mark", "full-shield", "FS", 0);
|
giveCard(state, "mark", "full-shield", "FS", 0);
|
||||||
state = must(state, "mark", { type: "counteract", instanceId: "full-shield#FS" });
|
state = must(state, "mark", { type: "counteract", instanceId: "full-shield#FS" });
|
||||||
while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" });
|
state = drain(state);
|
||||||
expect(state.players.find((p) => p.id === "mark")!.life).toBe(15);
|
expect(state.players.find((p) => p.id === "mark")!.life).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -845,7 +837,7 @@ describe("the imp's fire is a spell (rev 8)", () => {
|
|||||||
state = r.state;
|
state = r.state;
|
||||||
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
|
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
|
||||||
expect(state.stack?.kind).toBe("spell");
|
expect(state.stack?.kind).toBe("spell");
|
||||||
while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" });
|
state = drain(state);
|
||||||
const after = state.players.find((p) => p.id === "mark")!;
|
const after = state.players.find((p) => p.id === "mark")!;
|
||||||
expect(after.life).toBe(2);
|
expect(after.life).toBe(2);
|
||||||
expect(after.alive).toBe(true);
|
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", () => {
|
||||||
@@ -116,7 +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 });
|
||||||
while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" });
|
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);
|
||||||
});
|
});
|
||||||
@@ -684,52 +684,34 @@ describe("go away routs around walls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("the goat charges on legs, not wings (rev 10)", () => {
|
describe("the goat charges on legs, not wings (rev 10)", () => {
|
||||||
it("a ram beyond the turn's legal movement is refused", () => {
|
it("the charge walks real corridors: in reach it spends legs, beyond them it is refused", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
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)!;
|
||||||
// Find a cell more than 3 walked steps away and park the victim there.
|
|
||||||
const view = boardView(state);
|
|
||||||
let farCell: { x: number; y: number } | null = null;
|
|
||||||
for (const key of Object.keys(view.cells)) {
|
|
||||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
||||||
const r0 = applyCommand(state, attacker.id, { type: "endTurn", draw: 0 });
|
|
||||||
void r0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Use the engine itself as the oracle: try every cell until one refuses.
|
|
||||||
const bh = giveCard(state, attacker.id, "butt-head");
|
const bh = giveCard(state, attacker.id, "butt-head");
|
||||||
let refused = false;
|
// The engine is the oracle: every other square is either within the
|
||||||
for (const key of Object.keys(view.cells)) {
|
// 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];
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
defender.position = { x, y };
|
defender.position = { x, y };
|
||||||
const r = applyCommand(state, attacker.id, {
|
const r = applyCommand(state, attacker.id, {
|
||||||
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
|
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
|
||||||
});
|
});
|
||||||
if (!r.ok && /legs, not wings/.test(r.error)) { refused = true; break; }
|
if (!r.ok) {
|
||||||
if (r.ok) {
|
if (/legs, not wings/.test(r.error)) refusals += 1;
|
||||||
// In reach: the charge spends movement equal to the walked distance.
|
continue;
|
||||||
let st = r.state;
|
|
||||||
while (st.stack) {
|
|
||||||
const rr = applyCommand(st, st.stack.waitingOn, { type: "pass" });
|
|
||||||
if (!rr.ok) throw new Error(rr.error);
|
|
||||||
st = rr.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);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
// Whichever branch ran, exercise the other with the far corner.
|
expect(refusals).toBeGreaterThan(0);
|
||||||
if (!refused) {
|
expect(rams).toBeGreaterThan(0);
|
||||||
defender.position = { x: 0, y: 9 };
|
|
||||||
const r = applyCommand(state, attacker.id, {
|
|
||||||
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
|
|
||||||
});
|
|
||||||
if (!r.ok) refused = /legs, not wings/.test(r.error);
|
|
||||||
}
|
|
||||||
expect(refused || true).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -288,11 +288,11 @@ describe("the Ward is played in the moment", () => {
|
|||||||
expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" });
|
expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" });
|
||||||
// Nobody else may act while it hangs.
|
// Nobody else may act while it hangs.
|
||||||
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
|
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
|
||||||
let r = applyCommand(state, "owner", { type: "wardChoice", play: true });
|
const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
|
||||||
// Rev 9: the bite rides the stack — wave the counteraction window through.
|
|
||||||
if (r.ok && r.state.stack) r = applyCommand(r.state, r.state.stack.waitingOn, { type: "pass" });
|
|
||||||
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 };
|
||||||
|
|||||||
@@ -423,8 +423,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every targeted cast goes out through here so attachments never drop —
|
/** Attachment plumbing for a cast command: the chosen mods and the
|
||||||
* the attached NUMBER included, unless the call site already carried it. */
|
* attached NUMBER ride along (an explicit numberInstanceIds wins). */
|
||||||
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
|
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
|
||||||
applyMods(cmd);
|
applyMods(cmd);
|
||||||
if (attachedNumber && !cmd.numberInstanceIds) {
|
if (attachedNumber && !cmd.numberInstanceIds) {
|
||||||
@@ -695,7 +695,6 @@
|
|||||||
dispatch(withMods({
|
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;
|
||||||
@@ -880,7 +879,6 @@
|
|||||||
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();
|
||||||
}
|
}
|
||||||
@@ -901,7 +899,6 @@
|
|||||||
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;
|
||||||
@@ -958,13 +955,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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,6 @@
|
|||||||
: { x1: x * CELL, y1: (y + 1) * 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
|
||||||
* negative coordinates (the maze renormalizes after the landing). The
|
* negative coordinates (the maze renormalizes after the landing). The
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
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, edgeSelect = false, 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 }: {
|
||||||
@@ -224,11 +224,10 @@
|
|||||||
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. -----------------
|
||||||
// The effect must track ONLY the batch: the server sends `events` then
|
// Track ONLY the batch: the server sends `events` then `state` back to
|
||||||
// `state` back to back, so anything else in the dependency set (me, the
|
// back, so a wider dependency set re-runs this within the same frame,
|
||||||
// view) re-runs this before a zero-delay timer can fire — and an eager
|
// before a zero-delay timer can fire. No per-run cleanup for the same
|
||||||
// cleanup would cancel every spell before it left the wand. Timers die
|
// reason — timers die only with the component.
|
||||||
// only with the component.
|
|
||||||
let playedBatch = 0;
|
let playedBatch = 0;
|
||||||
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
|
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -250,9 +249,8 @@
|
|||||||
fxTimers.add(starter);
|
fxTimers.add(starter);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return undefined;
|
|
||||||
});
|
});
|
||||||
$effect(() => () => fxTimers.forEach(clearTimeout));
|
onDestroy(() => fxTimers.forEach(clearTimeout));
|
||||||
|
|
||||||
// --- Other bodies glide between views. -------------------------------
|
// --- Other bodies glide between views. -------------------------------
|
||||||
let prevView: GameView | null = null;
|
let prevView: GameView | null = null;
|
||||||
@@ -528,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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user