The table speaks plainer, and keeps its whimsy

Wording throughout the app, the server's messages and the engine's
refusals trades its stacked dashes for sentences, while the maze still
trusts you to honor its curses, the gallery still listens, and the
clockwork still learns its best tricks from the humans at the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-10-11 11:29:07 -04:00
co-authored by Claude Opus 5.5
parent ad23c83b8a
commit c64ff70ed4
25 changed files with 415 additions and 414 deletions
+28 -28
View File
@@ -2350,7 +2350,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const caster = activePlayer(state);
if (!target) return null;
const d = Math.abs(target.position.x - caster.position.x) + Math.abs(target.position.y - caster.position.y);
return d <= 1 ? null : "disease spreads by touch — you must be adjacent";
return d <= 1 ? null : "disease spreads by touch, so you must be adjacent";
},
sustains: true,
},
@@ -2434,7 +2434,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
validate: (state, _cmd, _caster, target) => {
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const { d, legs } = charge(state, activePlayer(state), target);
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
if (d > legs) return "the goat's legs cannot carry the charge that far this turn";
return null;
},
onResolved: (ctx) => {
@@ -2758,11 +2758,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
},
"opportunity-fire": {
kind: "neutral",
resolve: () => "played out of turn — wait for another player's turn, then use it",
resolve: () => "this is played during another player's turn",
},
interrupt: {
kind: "neutral",
resolve: () => "played out of turn — use it during another player's turn",
resolve: () => "this is played during another player's turn",
},
"thumb-of-god": {
kind: "neutral",
@@ -3061,7 +3061,7 @@ function emptySquareTarget(
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
// "You can't create an object on a DIMENSIONAL WARP".
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it";
return "nothing can form where the warp shimmers";
}
if (!castSight(state, caster, cmd, cell)) return "no line of sight";
return cell;
@@ -3417,7 +3417,7 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
if (creature.justCreated) return err("it cannot attack the turn it was created");
if (creature.attackUsed) return err("that creature has already attacked this turn");
if (creature.kind !== "troll" && creature.kind !== "skeleton" && creature.kind !== "shadow") {
return err("that creature attacks on its own, not on command");
return err("that creature picks its own victims and takes no orders to attack");
}
const events: GameEvent[] = [];
@@ -4068,7 +4068,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params, command.numberInstanceIds);
if (command.type === "pass") return doPass(state, playerId);
return err("an attack is being resolved — counteract or pass");
return err("an attack is being resolved, so counteract or pass");
}
if (state.chaosPending) {
@@ -4095,7 +4095,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
return err("chaos is coming — shield your hand or pass");
return err("chaos is coming, so shield your hand or pass");
}
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
@@ -4131,7 +4131,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
if (!fx || fx.kind !== "attack") return err("opportunity fire permits an attack");
}
if (kind === "interrupt" && command.type === "punch") {
return err("interrupt lets you cast a spell, not brawl");
return err("an interruption allows a spell but not a punch");
}
st.outOfTurnWindow = null;
const result = command.type === "cast" ? doCast(st, command) : doPunch(st, command.targetId);
@@ -4362,7 +4362,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
const shimmer = state.illusionWalls[key];
if (shimmer && shimmer.createdBy !== p.id && !shimmer.belief[p.id]) {
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("the wall shimmers oddly — test your eyes before you can pass");
return err("the wall shimmers oddly, and you must test your eyes before you can pass");
}
if (shimmer && illusionBelief(state, p.id, key) === "believes") {
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
@@ -4517,7 +4517,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
}
landing = canShoveTo(exits.includes(direction) ? direction : exits[0]!);
} else {
if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen} — the corridor leads ${exits.join(" or ")}`); }
if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen}, because the corridor leads ${exits.join(" or ")}`); }
landing = canShoveTo(chosen);
}
} else {
@@ -4568,16 +4568,16 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) {
p.position = from;
return err("the pit cannot be crossed from here — nothing to land on beside it");
return err("the pit cannot be crossed from here, with nothing to land on beside it");
}
if (exit !== undefined && !exits.includes(exit)) {
p.position = from;
return err(`no footing that way — the pit's rim leads ${exits.join(" or ")}`);
return err(`no footing that way, because the pit's rim leads ${exits.join(" or ")}`);
}
landing = exit ?? (bare.length === 1 ? bare[0]! : null);
if (landing === null) {
p.position = from;
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
return err(`the rim leads ${exits.join(" or ")}, so click the square to land on`);
}
const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell };
landingCell = foot.to;
@@ -4762,7 +4762,7 @@ function doPlayNumberForMovement(prev: GameState, instanceId: string, addInstanc
if (blocked) return err(blocked);
const mover = activePlayer(prev);
if (sustainedOn(prev, mover.id, "slow").length > 0) {
return err("you are slowed — no number cards for movement");
return err("a slowed wizard cannot play number cards for movement");
}
// "You may add two NUMBER cards together for any single action" — an ADD
// permits one extra movement number this turn.
@@ -4815,7 +4815,7 @@ function attackPreconditions(state: GameState): string | null {
}
return "you may attack only once per turn";
}
if (state.turn.attackForbidden) return "you are slowed — no attack this turn";
if (state.turn.attackForbidden) return "a slowed wizard cannot attack this turn";
return null;
}
@@ -4834,7 +4834,7 @@ function castingBlocked(
opts?: { counteraction?: boolean; cardId?: string; targetPlayerId?: PlayerId; pickedCardId?: 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, "no-spell").length > 0) return "No Spell holds you, and you cannot cast";
// 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 &&
@@ -4887,17 +4887,17 @@ function liftIdiotIfSatisfied(state: GameState, events: GameEvent[], p: PlayerSt
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
if (sustainedOn(state, target.id, "big-man").length > 0 &&
cellKey(attacker.position) === cellKey(target.position)) {
return "he fills the corridor — there is no room to swing";
return "he fills the corridor and leaves no room to swing";
}
if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush";
if (inThornbush(state, target)) return "you cannot attack someone in a thornbush";
if (isMisted(state, attacker.id)) return "you are mist — you may not attack";
if (isMisted(state, attacker.id)) return "as mist, you may not attack";
if (isMisted(state, target.id)) return "your target is mist and cannot be attacked";
// BUDDY: "Opponent will not attack you unless you attack first."
const buddy = state.sustained.find(
(s) => s.cardId === "buddy" && s.casterId === target.id && s.targetId === attacker.id,
);
if (buddy) return "the Buddy pact holds — you cannot bring yourself to attack them";
if (buddy) return "the Buddy pact holds, and you cannot bring yourself to attack them";
return null;
}
@@ -4986,7 +4986,7 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
/** Compat stub for stale clients: the Ward has no arming step. */
function doArmWard(): CommandResult {
return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
return err("the Ward is played in the moment, and you will be asked when your treasure is grabbed");
}
/** Player ids in seat order, starting after `fromId`. */
@@ -5032,7 +5032,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
const key = edgeKey(cell, side);
const wall = state.illusionWalls[key];
if (!wall) return err("there is no illusion there");
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
if (wall.createdBy === p.id) return err("you made it, so you know exactly what it is");
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
const events: GameEvent[] = [];
const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id)));
@@ -5368,13 +5368,13 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const ego = state.creatures.find((c) => c.id === cmd.via);
if (!ego || ego.kind !== "alter-ego" || ego.controllerId !== caster.id) return err("that is not your double");
if (!cmd.target) return err("your double casts only at a target");
if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body — not your double's to cast`);
if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body, so your double cannot cast it`);
origin = ego.position;
}
const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`);
if (effect.kind === "counter") {
return err(`${def.name} is a counteraction — play it in response to an attack`);
return err(`${def.name} is a counteraction, played in response to an attack`);
}
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`);
@@ -5914,7 +5914,7 @@ function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush"
if (!spell) return err("the attack to commit is not in your hand");
const fx = CARD_EFFECTS[spell.cardId];
if (!fx || fx.kind !== "attack") return err("commit an attack spell to the ambush");
if (fx.sameSquare) return err("that attack needs to share a square — no good from ambush");
if (fx.sameSquare) return err("that attack needs a shared square, so it cannot be sprung from ambush");
const numbers: CardInstance[] = [];
for (const id of cmd.numberInstanceIds ?? []) {
@@ -5932,7 +5932,7 @@ function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush"
if (state.squareContents[cellKey(cmd.cell)]?.kind === "stone") return err("that destination is solid stone");
}
if (!cmd.trigger || !["los", "near", "treasure"].includes(cmd.trigger.kind)) {
return err("choose a trigger: line of sight, close approach, or treasure");
return err("choose a trigger (line of sight, close approach, or treasure)");
}
// Commit the cards out of the hand; they return if the ambush is cancelled.
@@ -6223,7 +6223,7 @@ function doCounteract(
// duration-based spells, reducing effects by [its] value."
if (isNumberCard(card.cardId)) {
if (!displays(player, "shieldstone")) return err("only a displayed Shieldstone lets you counter with number cards");
if (stack.kind !== "spell") return err("shieldstone counters spells, not physical attacks");
if (stack.kind !== "spell") return err("shieldstone cannot counter a physical attack");
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false });
@@ -6269,7 +6269,7 @@ function doCounteract(
if (!targetCounter) return err("no counteraction to absorb");
const id = targetCounter.card.cardId;
if (id !== "reflection" && id !== "full-reflection") {
return err(`${cardDef(id).name} is not cast at you — it cannot be absorbed`);
return err(`${cardDef(id).name} is not cast at you, so it cannot be absorbed`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
@@ -799,7 +799,7 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
});
if (!r.ok) {
if (/legs, not wings/.test(r.error)) refusals += 1;
if (/cannot carry the charge that far/.test(r.error)) refusals += 1;
continue;
}
const st = drain(r.state);
@@ -839,7 +839,7 @@ describe("butt-head's charge reaches as far as its legs (rev 16)", () => {
const card = giveCard(state, goat.id, "butt-head");
const r = applyCommand(state, goat.id, { type: "cast", instanceId: card.instanceId, target: { kind: "player", playerId: mark.id } });
expect(r.ok).toBe(ok);
if (!r.ok) expect(r.error).toMatch(/legs, not wings/);
if (!r.ok) expect(r.error).toMatch(/cannot carry the charge that far/);
});
}
});