Idiot enforces its card (rev 23) + attack sight-line tracing
Rules rev 23 — IDIOT, per the card and FAQ: - No handling items: pick up / drop of treasures and objects refused (dropping was the exploit: capturing a stolen treasure on your own home, or dropping your own treasure underfoot for an instant cure) - No punching, no thrown dagger / large rock (attacks on players) - No effect on a victim already carrying one of their own treasures Ungated (permissive): counteractions are now castable while idiotized (the one thing the card expressly allows — the gate wrongly blocked them), and goal-aiding spells (IDIOT_AIDS: destroy-wall, teleport, mad-dash, ...) per the FAQ's 'you could, however, destroy a wall'. Sight tracing: while an LOS attack sits on the stack, the board draws the line it traveled — straight when direct, leg by leg through both warp mouths (with pulsing rings) when the maze's wraparound carried it. Engine traceSight/traceSightFor/stackSightTrace; overlay in Board.svelte; shown live and in replays. Verified against H3PC's cross-board Idiot cast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
7372bfc5e8
commit
cdeb3d5103
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
||||
import { cellKey, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
@@ -161,6 +161,58 @@ describe("expansion combat cards", () => {
|
||||
expect(r.error).toMatch(/blocked/);
|
||||
}
|
||||
});
|
||||
|
||||
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
// The victim carries an OPPONENT'S treasure into the spell.
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
||||
stolen.carriedBy = defender;
|
||||
stolen.position = null;
|
||||
d.carriedTreasureId = stolen.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
|
||||
// No dropping the carried treasure (no capturing it on your home, either).
|
||||
const drop = applyCommand(state, defender, { type: "dropTreasure" });
|
||||
expect(drop.ok).toBe(false);
|
||||
if (!drop.ok) expect(drop.error).toMatch(/treasure/);
|
||||
// No punching the tormentor.
|
||||
expect(applyCommand(state, defender, { type: "punch", targetId: attacker }).ok).toBe(false);
|
||||
// Goal-aiding spells stay castable (FAQ: "You could, however, destroy a wall").
|
||||
const sp = giveCard(state, defender, "speed", "SP", 0);
|
||||
expect(applyCommand(state, defender, { type: "cast", instanceId: sp.instanceId }).ok).toBe(true);
|
||||
// Counteractions are expressly allowed: absorb an incoming fireball.
|
||||
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
giveCard(state, defender, "absorb-spell", "AB", 1);
|
||||
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = defender;
|
||||
own.position = null;
|
||||
d.carriedTreasureId = own.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap home bases", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
||||
import { cellKey, edgeKey, type Cell } from "../src/board";
|
||||
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
|
||||
@@ -463,3 +463,40 @@ describe("junction alterations roll for their sector (rules rev 20)", () => {
|
||||
expect(createdKeys.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sight tracing", () => {
|
||||
it("agrees with sightBetween on every pair and pins entry/exit to the mouths", () => {
|
||||
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
const board = boardView(state);
|
||||
const cells = Object.keys(board.cells).map((k) => {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
return { x, y };
|
||||
});
|
||||
let warped = 0;
|
||||
for (const from of cells) {
|
||||
for (const to of cells) {
|
||||
const t = traceSight(board, from, to);
|
||||
expect(t !== null).toBe(sightBetween(board, from, to));
|
||||
if (!t) continue;
|
||||
if (hasLineOfSight(board, from, to)) {
|
||||
expect(t.kind).toBe("direct");
|
||||
} else {
|
||||
expect(t.kind).toBe("warp");
|
||||
if (t.kind === "warp") {
|
||||
warped++;
|
||||
// Each crossing point lies on its mouth's one-cell rim segment.
|
||||
const onRim = (p: { x: number; y: number }, m: { cell: Cell; side: string }) => {
|
||||
if (m.side === "N") return p.y === m.cell.y && p.x > m.cell.x && p.x < m.cell.x + 1;
|
||||
if (m.side === "S") return p.y === m.cell.y + 1 && p.x > m.cell.x && p.x < m.cell.x + 1;
|
||||
if (m.side === "W") return p.x === m.cell.x && p.y > m.cell.y && p.y < m.cell.y + 1;
|
||||
return p.x === m.cell.x + 1 && p.y > m.cell.y && p.y < m.cell.y + 1;
|
||||
};
|
||||
expect(onRim(t.entry, t.mouthA)).toBe(true);
|
||||
expect(onRim(t.exit, t.mouthB)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(warped).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user