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:
Eric Wagoner
2026-08-17 19:03:09 -04:00
co-authored by Claude Fable 5
parent 7372bfc5e8
commit cdeb3d5103
10 changed files with 290 additions and 24 deletions
+43 -2
View File
@@ -360,6 +360,41 @@ export function hasWarpLineOfSight(
to: Cell,
blockedCells?: Record<string, true>,
): boolean {
return warpSightTrace(board, from, to, blockedCells) !== null;
}
/**
* How a sight line reached its target — the material for drawing it. A warp
* trace carries the two mouths and the exact rim-crossing points (in board
* coordinates, where cell (x,y) spans [x,x+1]) so a renderer can draw the
* near leg to `entry` and the far leg from `exit`.
*/
export type SightTrace =
| { kind: "direct" }
| {
kind: "warp";
mouthA: { cell: Cell; side: Side };
mouthB: { cell: Cell; side: Side };
entry: { x: number; y: number };
exit: { x: number; y: number };
};
export function traceSight(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): SightTrace | null {
if (hasLineOfSight(board, from, to, blockedCells)) return { kind: "direct" };
return warpSightTrace(board, from, to, blockedCells);
}
function warpSightTrace(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): Extract<SightTrace, { kind: "warp" }> | null {
const DIR: Record<Side, { x: number; y: number }> = {
N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 },
};
@@ -423,10 +458,16 @@ export function hasWarpLineOfSight(
segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells,
[cellKey(to), cellKey(mouthB)])
) {
return true;
return {
kind: "warp",
mouthA: { cell: mouthA, side: sideA },
mouthB: { cell: mouthB, side: w.to.side },
entry: P,
exit: Pfar,
};
}
}
return false;
return null;
}
/** The game's full line-of-sight check: direct, or through a wraparound opening. */
+57 -8
View File
@@ -2227,6 +2227,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive) return;
const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position);
if (!hasTreasureOut) return; // "ends if both treasures are being carried"
// FAQ: "If you happen to already be carrying one of your treasures
// when you are hit with this spell, it has no effect on you."
if ((ctx.state.config.deckRev ?? 1) >= 23) {
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
if (carried?.owner === ctx.defender.id) return;
}
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
},
},
@@ -3987,10 +3993,43 @@ function attackPreconditions(state: GameState): string | null {
return null;
}
function castingBlocked(state: GameState, playerId: PlayerId): string | null {
/**
* IDIOT's FAQ carves out spells that "help you in your goal" of reaching the
* treasure — barrier-removal, passage, and movement boosts. Attacks and
* everything aimed at other players stay forbidden.
*/
const IDIOT_AIDS = new Set([
"destroy-wall", "pass-through-wall", "teleport", "power-run", "mad-dash",
"speed", "mist-body", "flight", "exploding-door", "stone-to-water",
"create-door", "vampire", "werewolf",
]);
function castingBlocked(
state: GameState, playerId: PlayerId,
opts?: { counteraction?: boolean; cardId?: string },
): string | null {
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa";
if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast";
if (sustainedOn(state, playerId, "idiot").length > 0) return "What am I doing here...? (you can do nothing but head for your treasure)";
// IDIOT: "He can do nothing else but cast COUNTERACTION spells (if
// needed)" — and per the FAQ, spells that aid the march to the treasure.
if (sustainedOn(state, playerId, "idiot").length > 0 &&
!opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) {
return "What am I doing here...? (you can do nothing but head for your treasure)";
}
return null;
}
/**
* IDIOT forbids handling items and attacking players, not just casting:
* "it does not allow you to attack players or pick up other items".
* Dropping is the sharp edge — an idiot could otherwise capture a stolen
* treasure on their own home, or drop their own treasure underfoot for an
* instant cure.
*/
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
if ((state.config.deckRev ?? 1) >= 23 && sustainedOn(state, playerId, "idiot").length > 0) {
return "What am I doing here...? (you can do nothing but head for your treasure)";
}
return null;
}
@@ -4131,7 +4170,7 @@ function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
}
function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
const pre = attackPreconditions(prev);
const pre = attackPreconditions(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (pre) return err(pre);
const state = clone(prev);
@@ -4348,11 +4387,16 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// NO SPELL cast on you."
const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand;
if (isSpell) {
const castBlock = castingBlocked(state, caster.id);
const castBlock = castingBlocked(state, caster.id, { cardId: inHand.cardId });
if (castBlock) return err(castBlock);
} else if (sustainedOn(state, caster.id, "medusa").length > 0) {
return err("you are paralyzed by Medusa");
}
// Thrown objects are attacks on players — not among IDIOT's permitted aids.
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
const ib = idiotBlocked(state, caster.id);
if (ib) return err(ib);
}
const mods = gatherModifiers(caster, cmd);
if (typeof mods === "string") return err(mods);
@@ -4855,8 +4899,9 @@ function doCounteract(
const def = cardDef(card.cardId);
// "Opponent cannot move or cast spells, including COUNTERACTIONs" (MEDUSA);
// NO SPELL blocks all spells too.
const castBlock = castingBlocked(state, playerId);
// NO SPELL blocks all spells too. IDIOT does not — counteractions are the
// one thing its victim is expressly allowed.
const castBlock = castingBlocked(state, playerId, { counteraction: true });
if (castBlock) return err(castBlock);
// "REFLECTIONS have no effect" against CHAOS (rules rev 3).
@@ -5431,7 +5476,7 @@ function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null {
// --- Treasures ---------------------------------------------------------------
function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
const blocked = requireActionsAvailable(prev);
const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (blocked) return err(blocked);
const state = clone(prev);
@@ -5480,7 +5525,7 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
}
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (blocked) return err(blocked);
const state = clone(prev);
@@ -5523,6 +5568,8 @@ export function isMovableObject(cardId: string): boolean {
}
function doDropObject(prev: GameState, instanceId: string): CommandResult {
const ib = idiotBlocked(prev, activePlayer(prev).id);
if (ib) return err(ib);
const state = clone(prev);
const p = activePlayer(state);
const card = p.hand.find((c) => c.instanceId === instanceId);
@@ -5539,6 +5586,8 @@ function doDropObject(prev: GameState, instanceId: string): CommandResult {
}
function doDropTreasure(prev: GameState): CommandResult {
const ib = idiotBlocked(prev, activePlayer(prev).id);
if (ib) return err(ib);
const state = clone(prev);
const p = activePlayer(state);
if (!p.carriedTreasureId) return err("you are not carrying a treasure");
+44 -7
View File
@@ -2,8 +2,8 @@
// The server sends this after every state change; clients never see the
// deck order or other players' hands.
import { sightBetween, type AssembledBoard } from "./board";
import { type CardInstance } from "./cards";
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, type CardInstance } from "./cards";
import {
boardView,
LOS_BLOCKING_CONTENT,
@@ -173,6 +173,16 @@ export function sightedCellsFor(view: GameView): Set<string> {
const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you);
if (!me) return out;
const { board, blockers } = sightBasis(view);
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
}
return out;
}
/** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// Held-open doors are open doorways to the eye (rules rev 15).
let board = view.board;
if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) {
@@ -189,11 +199,38 @@ export function sightedCellsFor(view: GameView): Set<string> {
blockers[`${p.position.x},${p.position.y}`] = true;
}
}
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
}
return out;
return { board, blockers };
}
/**
* How one square sees another under this viewer's knowledge of the board
* (believed illusion walls block; held doors admit). Null when no sight
* exists the renderer's material for drawing the line an attack traveled.
*/
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
const { board, blockers } = sightBasis(view);
return traceSight(board, from, to, blockers);
}
/**
* The sight line behind the attack currently on the stack the board's
* answer to "how can he even see me?". Null when nothing should draw:
* no stack, a creature's or physical attack, a non-LOS card, attacker and
* defender sharing a square, or no sight under this viewer's knowledge
* (a believed illusion wall can honestly hide the line).
*/
export function stackSightTrace(
view: GameView,
): { from: Cell; to: Cell; trace: SightTrace } | null {
const stack = view.stack;
if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
const a = view.players.find((p) => p.id === stack.attackerId);
const d = view.players.find((p) => p.id === stack.defenderId);
if (!a || !d) 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);
return trace ? { from: a.position, to: d.position, trace } : null;
}
const CREATION_CARD_IDS = new Set([
+53 -1
View File
@@ -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);
});
});
+1 -1
View File
@@ -54,7 +54,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 = 22;
const RULES_REV = 23;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+6 -1
View File
@@ -10,7 +10,7 @@
import { scheduleFx, type BoardFx } from "./fx";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine";
net.connect();
@@ -854,6 +854,10 @@
t.position.y === me.position.y && !t.carriedBy),
);
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
// While an LOS attack sits on the stack, draw the line it traveled — the
// answer to "how can he even see me?" when sight ran through a warp mouth.
const sightTrace = $derived(view ? stackSightTrace(view) : null);
const litCells = $derived.by(() => {
if (!view || !selectedDef || !yourMoment) return null;
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
@@ -1386,6 +1390,7 @@
onGhostClick={clickGhostSlot}
onCellPeek={(c) => peekAt(c)}
{litCells}
{sightTrace}
/>
</section>
+41 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Component } from "svelte";
import type { GameView } from "@wizwar/engine";
import type { GameView, SightTrace } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import type { BoardFx } from "./fx";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
@@ -28,6 +28,7 @@
ghostSlots = null,
onGhostClick,
effects = null,
sightTrace = null,
}: {
view: GameView;
edgeSelectMode?: boolean;
@@ -54,6 +55,9 @@
onGhostClick?: (origin: { x: number; y: number }) => void;
/** Short-lived spell flourishes; purely cosmetic. */
effects?: BoardFx[] | null;
/** The sight line an attack in progress traveled — proof against "how
* can he even see me?", drawn leg by leg through any warp mouth. */
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null;
} = $props();
@@ -601,6 +605,23 @@
{/if}
{/each}
{/if}
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
{#if sightTrace}
{@const A = { x: sightTrace.from.x * CELL + CELL / 2, y: sightTrace.from.y * CELL + CELL * 0.36 }}
{@const B = { x: sightTrace.to.x * CELL + CELL / 2, y: sightTrace.to.y * CELL + CELL * 0.36 }}
<g class="sight-layer" aria-hidden="true">
{#if sightTrace.trace.kind === "direct"}
<line x1={A.x} y1={A.y} x2={B.x} y2={B.y} class="sight-line" />
{:else}
{@const entry = { x: sightTrace.trace.entry.x * CELL, y: sightTrace.trace.entry.y * CELL }}
{@const exit = { x: sightTrace.trace.exit.x * CELL, y: sightTrace.trace.exit.y * CELL }}
<line x1={A.x} y1={A.y} x2={entry.x} y2={entry.y} class="sight-line" />
<line x1={exit.x} y1={exit.y} x2={B.x} y2={B.y} class="sight-line" />
<circle cx={entry.x} cy={entry.y} r={7} class="sight-mouth" />
<circle cx={exit.x} cy={exit.y} r={7} class="sight-mouth" />
{/if}
</g>
{/if}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)}
@@ -757,6 +778,24 @@
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
.fx-layer { pointer-events: none; }
.sight-layer { pointer-events: none; }
.sight-line {
stroke: #c9a72a;
stroke-width: 2.5;
stroke-linecap: round;
stroke-dasharray: 7 6;
opacity: 0.85;
animation: sight-march 0.8s linear infinite;
}
.sight-mouth {
fill: none;
stroke: #c9a72a;
stroke-width: 2;
animation: warp-pulse 1.6s ease-in-out infinite;
}
@keyframes sight-march {
to { stroke-dashoffset: -13; }
}
.mover { transition: transform 260ms cubic-bezier(0.25, 0.8, 0.35, 1); }
.mover.snap { transition: none; }
@@ -783,6 +822,7 @@
}
@media (prefers-reduced-motion: reduce) {
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
.sight-line, .sight-mouth { animation: none; }
.fx-layer { display: none; }
.firewall, :global(.token-art.ghosted) { animation: none; }
.mover { transition: none; }
+6 -1
View File
@@ -2,6 +2,7 @@
import Board from "./Board.svelte";
import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
@@ -21,6 +22,10 @@
);
const atEnd = $derived(idx >= steps.length - 1);
// The reel draws the same sight line the live table shows for an LOS
// attack in progress, so a replay-watcher can see how a spell reached them.
const sightTrace = $derived(stackSightTrace(step.view));
/** Each step's spells flare on the reel exactly as they did at the table. */
let boardFx = $state<BoardFx[]>([]);
$effect(() => {
@@ -61,7 +66,7 @@
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
</header>
<div class="replay-board">
<Board view={step.view} effects={boardFx} />
<Board view={step.view} effects={boardFx} {sightTrace} />
</div>
<div class="replay-caption">
<strong>{step.actor}</strong>
+1 -1
View File
@@ -136,7 +136,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 22,
deckRev: 23,
};
const { state, events } = createGame(config);
for (const e of events) {