Credibility pass: one voice, no scars

A three-reviewer sweep for tells of piecemeal machine generation,
every finding verified against the code before touching it. No
behavior changes; the full suite passes unchanged (plus two
strengthened pins).

Engine: removed four void-silenced fossils (a parseEdgeKey call
voided where it stood, stoneEffect's ignored cardId parameter, the
actualTarget remnant in doCast, a voided loop variable in shadow
upkeep); fixed the initialize-then-overwrite narration in
spawnCreature; replaced a filter(() => false) no-op; waterwall now
rides waveFromEdge instead of carrying its own verbatim copy (and the
single-caller washBack wrapper went with it); blind wall-bumps and
LOS blockers each collapsed to one implementation; the wand-id list
and the "permanent" duration sentinel became named constants; the
ambush number local no longer shadows the imported numberValue
function; assorted reviewer-aimed phrasings rewritten as the
constraints they guard.

Server/deploy: the protocol header now documents all eleven message
types; dropped an eslint pragma with no eslint, a test script with no
tests, and an rsync exclude anchored at a path that never existed
(the real data/ dir now excluded); the Caddy vhost has one source of
truth; stale "pending DNS" note removed — the record resolves.

Web: ~90 lines of CSS swallowed verbatim into a mobile media query
deduplicated; the reduced-motion guard on the board now actually
stops the marked-cell pulse; one shared color module replaces two
drifted palettes; an orphaned doc comment rejoined its function.

Tests: the ten-times-pasted helper block became test/helpers.ts;
wave-numbered files renamed for the behaviors they pin; deliberation
comments and void-ed corpses of unwritten assertions deleted; silent
seed-dependent early-returns now fail loudly; one assertion that
compared a value to itself now pins the home-translation it meant to;
the stored-log single-number command form gained the explicit
compatibility test it deserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 11:56:55 -04:00
co-authored by Claude Fable 5
parent f62fcf2510
commit 695307daa8
24 changed files with 240 additions and 716 deletions
+1
View File
@@ -4,3 +4,4 @@ dist/
.env
.DS_Store
data/
.claude/
-12
View File
@@ -1,12 +0,0 @@
# Caddy terminates TLS (automatic certificates) and proxies to the game.
# Replace the hostname with your domain, or use the sslip.io form
# (wizwar.<droplet-ip>.sslip.io) for zero DNS setup.
{$WIZWAR_HOST}
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
reverse_proxy localhost:8787
+2 -2
View File
@@ -8,7 +8,7 @@ survive deploys and reboots.
## Current production
- Droplet: `wizwar` (nyc3, s-1vcpu-1gb, tag `wizwar`), IP 104.236.96.198
- URLs: https://wizwar.kestrelsnest.social (once the Hover A record lands)
- URLs: https://wizwar.kestrelsnest.social
and https://wizwar.104.236.96.198.sslip.io (always works, zero DNS).
Caddy serves both; NOTE: kestrelsnest.social's authoritative DNS is at
HOVER (ns1/ns2.hover.com), not DigitalOcean — records must be added there.
@@ -18,7 +18,7 @@ survive deploys and reboots.
deploy/deploy.sh 104.236.96.198
Builds the client locally, rsyncs the repo (minus node_modules, /data, .git,
Builds the client locally, rsyncs the repo (minus node_modules, data/, .git,
research), installs dependencies on the droplet, and restarts the service.
Games in progress survive: state lives in room files, and clients reconnect
automatically.
+1 -1
View File
@@ -5,7 +5,7 @@ HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
npm run build --workspace=@wizwar/web
rsync -az --delete \
--exclude node_modules --exclude /data --exclude .git --exclude research \
--exclude node_modules --exclude data/ --exclude .git --exclude research \
./ "root@$HOST:/opt/wizwar/"
ssh "root@$HOST" '
cd /opt/wizwar && npm install --no-audit --no-fund
+1 -1
View File
@@ -24,7 +24,7 @@ id -u wizwar &>/dev/null || useradd -r -m -d /opt/wizwar-home wizwar
mkdir -p /opt/wizwar /var/lib/wizwar/rooms
chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar
# Caddy vhost (security headers included; see deploy/Caddyfile for the template)
# Caddy vhost: auto-TLS, security headers, proxy to the game.
printf '%s\n\nheader {\n\tStrict-Transport-Security "max-age=31536000"\n\tX-Content-Type-Options "nosniff"\n\tX-Frame-Options "DENY"\n\tReferrer-Policy "no-referrer"\n}\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile
systemctl reload caddy
+55 -88
View File
@@ -133,6 +133,11 @@ export interface SquareContent {
createdBy: PlayerId;
}
const WAND_CARD_IDS = ["blaster-wand", "shift-wand", "sticky-wand", "warp-wand"] as const;
/** Duration meaning "for the rest of the game" ("This card is permanent."). */
const PERMANENT_TURNS = 1_000_000_000;
/** Which square contents block line of sight. */
export const LOS_BLOCKING_CONTENT: Record<SquareContent["kind"], boolean> = {
stone: true, thornbush: true, rosebush: true, dust: true, slime: true,
@@ -265,17 +270,21 @@ export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: strin
return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId));
}
/** LOS including square-filling blockers (stone, thornbushes). */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
/** Square-filling sight blockers: stone, bushes — and the BIG MAN, whom no spell passes. */
export function losBlockers(state: GameState): Record<string, true> {
const blockers: Record<string, true> = {};
for (const [key, content] of Object.entries(state.squareContents)) {
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
}
// BIG MAN: you cannot cast spells past him.
for (const p of state.players) {
if (p.alive && sustainedOn(state, p.id, "big-man").length > 0) blockers[cellKey(p.position)] = true;
}
return hasLineOfSight(boardView(state), from, to, blockers);
return blockers;
}
/** LOS including square-filling blockers. */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
return hasLineOfSight(boardView(state), from, to, losBlockers(state));
}
/** Parse an edge key back into its north/west cell and side. */
@@ -328,9 +337,7 @@ function perceivedBoard(
}
// Untested: only roll if this sight line would actually cross it.
if (sightLine) {
const { cell, side } = parseEdgeKey(key);
const test = { ...view, edges: { [key]: "wall" as const } };
void cell; void side;
const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to);
if (crossesIt) {
if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall";
@@ -354,10 +361,7 @@ function casterLos(
events: GameEvent[] = [],
): boolean {
const board = perceivedBoard(state, events, caster.id, { from, to });
const blockers: Record<string, true> = {};
for (const [key, content] of Object.entries(state.squareContents)) {
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
}
const blockers = losBlockers(state);
if (hasLineOfSight(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false;
for (const key of Object.keys(board.edges)) {
@@ -568,7 +572,7 @@ export type Command =
instanceId: string;
/** Number cards powering the cast (two allowed when an ADD is attached). */
numberInstanceIds?: string[];
/** Legacy single-number field; merged into numberInstanceIds. */
/** Single-number form still present in stored command logs; folded into numberInstanceIds. */
numberInstanceId?: string;
/** AMPLIFY cards attached (each doubles power/duration). */
amplifyInstanceIds?: string[];
@@ -1074,7 +1078,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!target || !target.alive) return "no such living player";
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
// Effectively permanent: broken by the caster attacking the target.
attachSustained(state, events, "buddy", caster.id, target.id, 1_000_000_000);
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
return null;
},
},
@@ -1193,29 +1197,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
// The two sides of the edge, and the push directions away from it.
const a = cell;
const b = neighbor(cell, side);
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
const pushes: { start: Cell; dir: Side }[] = [
{ start: a, dir: away(side) },
{ start: b, dir: side },
];
for (const { start, dir } of pushes) {
// Players on the two cells extending away from the edge on this side.
let probe = start;
for (let dist = 0; dist < 2; dist++) {
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(probe)) continue;
washBack(state, events, p, dir);
}
for (const c of [...state.creatures]) {
if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) {
destroyCreature(state, events, c, "waterwall");
}
}
probe = neighbor(probe, dir);
}
}
waveFromEdge(state, events, cell, side, 2, "waterwall");
checkVictory(state, events);
return null;
},
@@ -1303,11 +1285,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
},
},
// --- Magic stones ---------------------------------------------------------
bloodstone: stoneEffect("bloodstone"),
powerstone: stoneEffect("powerstone"),
shadowstone: stoneEffect("shadowstone"),
soulstone: stoneEffect("soulstone"),
speedstone: stoneEffect("speedstone", (state, _events, caster) => {
bloodstone: stoneEffect(),
powerstone: stoneEffect(),
shadowstone: stoneEffect(),
soulstone: stoneEffect(),
speedstone: stoneEffect((state, _events, caster) => {
// "Your movement rate is increased by 1" — starting now, not next turn.
// A delta (not a recompute) so number cards already played stay counted.
// No bump while SLOW forces 1, or when this turn's movement is already
@@ -1318,9 +1300,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
state.turn.movementAllowance += 1;
}
}),
shieldstone: stoneEffect("shieldstone"),
visionstone: stoneEffect("visionstone"),
brainstone: stoneEffect("brainstone", (state, events, caster) => {
shieldstone: stoneEffect(),
visionstone: stoneEffect(),
brainstone: stoneEffect((state, events, caster) => {
// "Draw two more cards, now."
const drawn: CardInstance[] = [];
for (let i = 0; i < 2; i++) {
@@ -1339,7 +1321,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
// "This is permanent. Once SLOW DEATH is on, it can't be turned off."
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
},
},
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
@@ -1470,7 +1452,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
kind: "neutral",
resolve: (state, events, caster) => {
if (state.players.filter((p) => p.alive).length <= 2) return "not applicable in a 2-player game";
attachSustained(state, events, "lifesaver", caster.id, caster.id, 1_000_000_000);
attachSustained(state, events, "lifesaver", caster.id, caster.id, PERMANENT_TURNS);
return null;
},
},
@@ -1543,7 +1525,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
resolve: (state, events, caster, cmd) => {
const wanted = cmd.params?.cardId;
if (!wanted) return "name the card to retrieve";
if (["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"].includes(wanted)) {
if ((WAND_CARD_IDS as readonly string[]).includes(wanted)) {
return "deja-vu cannot retrieve a magic wand";
}
for (let i = state.discard.length - 1; i >= 0; i--) {
@@ -1680,7 +1662,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
return "both squares must hold an item";
}
if (itemsA.length > 0 || itemsB.length > 0) {
if (itemsA.length > 0) state.groundObjects[kb] = [...itemsB.filter(() => false), ...itemsA];
if (itemsA.length > 0) state.groundObjects[kb] = [...itemsA];
else delete state.groundObjects[kb];
if (itemsB.length > 0) state.groundObjects[ka] = [...itemsB];
else delete state.groundObjects[ka];
@@ -1773,7 +1755,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
// "1/2 point of damage for every space moved. This spell is permanent."
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
},
},
disease: {
@@ -2040,7 +2022,7 @@ 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"
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
},
},
"big-man": {
@@ -2268,7 +2250,7 @@ function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
}
/** A collapsing waterwall wave from an edge: wash players back `range`. */
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number): void {
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
const pushes: { start: Cell; dir: Side }[] = [
{ start: cell, dir: away(side) },
@@ -2282,7 +2264,7 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
}
for (const c of [...state.creatures]) {
if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) {
destroyCreature(state, events, c, "rushing water");
destroyCreature(state, events, c, reason);
}
}
probe = neighbor(probe, dir);
@@ -2334,8 +2316,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
}
/** A displayable stone: casting it turns it face-up; its power is passive. */
function stoneEffect(cardId: string, onDisplay?: (state: GameState, events: GameEvent[], caster: PlayerState) => void): NeutralEffect {
void cardId;
function stoneEffect(onDisplay?: (state: GameState, events: GameEvent[], caster: PlayerState) => void): NeutralEffect {
return {
kind: "neutral",
keepInHand: true,
@@ -2398,10 +2379,6 @@ function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: S
}
}
function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void {
washBackN(state, events, p, dir, 2);
}
// ---------------------------------------------------------------------------
// Creatures
@@ -2439,15 +2416,13 @@ function spawnCreature(
damage: 0,
maxDamage: stats.maxDamage,
movesPerTurn: stats.moves,
movementUsed: stats.moves, // no movement on the creation turn's remainder...
movementUsed: 0, // "It may move on that turn." (Exp1 sheet)
attackUsed: true, // "cannot attack the turn they are created"
justCreated: true,
wallPassesPerTurn: stats.wallPasses,
wallPassUsed: 0,
scorchedThisTurn: [],
};
// "...but may move on that turn." (Exp1 sheet) — movement allowed at once.
creature.movementUsed = 0;
state.creatures.push(creature);
events.push({ type: "creatureCreated", creatureId: creature.id, kind, controller: controllerId, at });
return creature;
@@ -3157,6 +3132,13 @@ function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null {
// --- Movement ---------------------------------------------------------------
/** A blind wizard's wasted lurch into a wall still costs a movement point. */
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
}
function doMove(prev: GameState, direction: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
@@ -3188,11 +3170,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
const key = edgeKey(p.position, direction);
if (state.illusionWalls[key] &&
illusionBelief(state, events, p.id, key) === "believes") {
if (isBlinded(state, p)) {
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
}
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("blocked by wall");
}
}
@@ -3209,11 +3187,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
const edge = view.edges[key] ?? "open";
const dest = neighbor(p.position, direction);
if (!view.cells[cellKey(dest)]) {
if (isBlinded(state, p)) {
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
}
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("blocked");
}
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
@@ -3235,10 +3209,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
p.position = dest;
via = "passWall";
} else if (isBlinded(state, p)) {
// Blind bump: the wasted lurch costs a movement point.
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
return blindBump(state, events, p, direction);
} else {
return err(`blocked by ${target.by}`);
}
@@ -3739,7 +3710,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// Magic wands: charged on first use by the number card(s) played; one
// charge per use, one use per turn; discarded when the last charge goes.
const WANDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]);
const WANDS = new Set<string>(WAND_CARD_IDS);
const isWand = WANDS.has(inHand.cardId);
if (isWand && state.turn.wandsUsed.includes(inHand.instanceId)) {
return err("any wand operates a maximum of once per turn");
@@ -3877,7 +3848,6 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
// go intended distance" — if the die disagrees with the true direction,
// the spell hits whoever lies that way, or dissipates.
let actualTarget = target;
if (isBlinded(state, caster) &&
cellKey(target.position) !== cellKey(caster.position)) {
const dx = target.position.x - caster.position.x;
@@ -3904,7 +3874,6 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
rolledDirection: rolled, newTarget: along?.id ?? null,
}];
if (!along) return { ok: true, state, events: missEvents }; // dissipates
actualTarget = along;
state.stack = {
attackerId: caster.id,
defenderId: along.id,
@@ -3926,7 +3895,6 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
return { ok: true, state, events: missEvents };
}
}
void actualTarget;
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
@@ -3994,7 +3962,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
state.lastSpellUsed[caster.id] = inHand.cardId;
}
const result = (effect as NeutralEffect).resolve(state, events, caster, cmd, mods.magnitude);
if (result) return err(result); // unreachable after preview
if (result) return err(result); // non-null here means resolve and preview disagree
return { ok: true, state, events };
}
@@ -4107,7 +4075,7 @@ function checkAmbushes(
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
const numberValue = ambush.numbers.length > 0
const numberTotal = ambush.numbers.length > 0
? ambush.numbers.reduce((t, c) => t + (cardDef(c.cardId).value ?? 0), 0)
: null;
events.push({
@@ -4118,7 +4086,7 @@ function checkAmbushes(
attackerId: owner.id,
defenderId: actor.id,
attackCard: ambush.spell,
numberValue,
numberValue: numberTotal,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
@@ -4129,7 +4097,7 @@ function checkAmbushes(
};
events.push({
type: "spellCast", caster: owner.id, card: ambush.spell, cardId: ambush.spell.cardId,
numberCards: ambush.numbers, numberValue,
numberCards: ambush.numbers, numberValue: numberTotal,
from: owner.position, target: actor.id, targetCell: actor.position,
});
return; // one ambush per check; others may spring on later steps
@@ -4152,7 +4120,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
if (playerId === stack.defenderId) {
if (card.cardId === "absorb-spell") {
if (stack.kind !== "spell") return err("absorb spell only works against spells");
if (stack.attackCard && ["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"].includes(stack.attackCard.cardId)) {
if (stack.attackCard && (WAND_CARD_IDS as readonly string[]).includes(stack.attackCard.cardId)) {
return err("Absorb Spell has no effect on magic wands");
}
takeFromHand(player, instanceId);
@@ -4225,7 +4193,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
}
if (playerId === stack.attackerId) {
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction (for now)");
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction");
const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified);
if (!targetCounter) return err("no counteraction to nullify");
takeFromHand(player, instanceId);
@@ -4601,8 +4569,7 @@ function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
* charges are keyed by instance, so they travel automatically).
*/
const MOVABLE_OBJECT_CARD_IDS = new Set([
"dagger", "large-rock", "wizardblade",
"blaster-wand", "shift-wand", "sticky-wand", "warp-wand",
"dagger", "large-rock", "wizardblade", ...WAND_CARD_IDS,
]);
export function isMovableObject(cardId: string): boolean {
@@ -4751,13 +4718,13 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
}
// SHADOW upkeep: 1 life per turn, even during lost turns (handled where
// turns are skipped too).
for (const c of state.creatures.filter((c) => c.kind === "shadow" && c.controllerId === player.id)) {
const shadows = state.creatures.filter((c) => c.kind === "shadow" && c.controllerId === player.id).length;
for (let i = 0; i < shadows; i++) {
player.life -= 1;
events.push({ type: "shadowUpkeep", player: player.id, lifeAfter: player.life });
if (player.life <= 0) {
applyDamage(state, events, player, 0, "shadow upkeep", null); // triggers death path at <=0
}
void c;
}
// Duration spells expire at the start of their CASTER's turns.
-1
View File
@@ -157,7 +157,6 @@ export function sightedCellsFor(view: GameView): Set<string> {
for (const [key, content] of Object.entries(view.squareContents)) {
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
}
// BIG MAN: you cannot cast spells past him.
for (const p of view.players) {
if (p.alive && view.sustained.some((s) => s.cardId === "big-man" && s.targetId === p.id)) {
blockers[`${p.position.x},${p.position.y}`] = true;
+19 -43
View File
@@ -1,50 +1,12 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { applyCommand, activePlayer, boardView } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T"): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[0] = instance;
return instance;
}
/** Advance past round 1 (both players just end their turns). */
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(state.turn.round).toBe(2);
return state;
}
/** Put attacker and defender in mutual LOS (same square works for spells too). */
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
describe("attack spells", () => {
it("fireball does 5 flat damage when unopposed", () => {
let { state } = newGame();
@@ -174,9 +136,7 @@ describe("attack spells", () => {
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Stand defender 1 east of attacker with open space behind (find a spot):
// use same square then nudge — simplest robust arrangement: same square,
// knockback direction defaults to none, so instead place east if open.
// Same square: waterbolt needs a legal target; knockback is the engine's problem.
defender.position = { ...attacker.position };
const wb = giveCard(state, attacker.id, "waterbolt");
const a = state.players.find((p) => p.id === attacker.id)!;
@@ -338,3 +298,19 @@ describe("the post-game reveal", () => {
expect(fallen.map((c) => c.cardId).sort()).toEqual(["fireball", "number-6"]);
});
});
describe("stored-log compatibility", () => {
it("replays the single-number command form older logs contain", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
giveCard(state, attacker, "number-3", "N", 1);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, numberInstanceId: "number-3#N",
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15 - 5); // fireball: 2 + the 3
});
});
+4 -53
View File
@@ -1,54 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
creatureAt,
gameLos,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId } from "../src/game";
import { cellKey, SIDES, stepTarget, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
/** Summon a creature next to its creator (round 2+, consumes the attack). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
@@ -171,8 +125,7 @@ describe("monsters", () => {
const w = state.creatures[0]!;
const enemy2 = state.players.find((p) => p.id !== me)!;
enemy2.position = { ...w.position };
// step the wraith one cell and back onto the enemy? Simply move enemy onto
// wraith is not a touch (wraith must enter). Move wraith away then back.
// A touch requires the WRAITH to enter the square — step it away and back.
const view2 = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view2, w.position, side);
@@ -200,8 +153,6 @@ describe("monsters", () => {
state = must(state, me, { type: "endTurn", draw: 0 });
const enemy = state.players.find((p) => p.id !== me)!;
// (enemy may have been scorched at turn start if in LOS — note life)
const enemyLife = enemy.life;
void enemyLife;
const enemyNow = state.players.find((p) => p.id !== me)!;
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
const fb = giveCard(state, enemy.id, "fireball", "F", 0);
@@ -1,57 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { applyCommand, activePlayer, boardView, sustainedOn, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
function castAt(
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
extra: Partial<Extract<Command, { type: "cast" }>> = {},
): GameState {
state = must(state, attacker, {
type: "cast", instanceId: card.instanceId,
target: { kind: "player", playerId: defender }, ...extra,
});
return must(state, defender, { type: "pass" });
}
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
describe("duration spells", () => {
it("slow reduces movement to 1, blocks number cards, and halves attacks", () => {
@@ -113,8 +64,6 @@ describe("duration spells", () => {
state = must(state, attacker, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(defender);
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
const counter = giveCard(state, defender, "blunt", "B", 0);
void counter;
state = must(state, defender, { type: "endTurn", draw: 0 });
// Attacker punches the frozen defender: no damage.
@@ -276,7 +225,7 @@ describe("movement spells", () => {
break;
}
}
if (!dir) return; // no adjacent wall on this seed's home; fine
if (!dir) throw new Error("setup: seed 42 lost its adjacent wall");
const ptw = giveCard(state, me.id, "pass-through-wall");
state = must(state, me.id, { type: "cast", instanceId: ptw.instanceId });
state = must(state, me.id, { type: "move", direction: dir });
+6 -55
View File
@@ -1,58 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
boardView,
createGame,
gameLos,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
import { cellKey, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
function castAt(
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
extra: Partial<Extract<Command, { type: "cast" }>> = {},
): GameState {
state = must(state, attacker, {
type: "cast", instanceId: card.instanceId,
target: { kind: "player", playerId: defender }, ...extra,
});
return must(state, defender, { type: "pass" });
}
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
describe("expansion combat cards", () => {
it("power attack burns life for extra damage", () => {
@@ -104,9 +54,8 @@ describe("expansion combat cards", () => {
if (r.ok) { state = r.state; steps++; }
if (steps === 2) break;
}
if (steps === 2) {
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
}
expect(steps).toBe(2);
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
});
it("mental swap trades entire hands", () => {
@@ -208,6 +157,8 @@ describe("expansion combat cards", () => {
if (r.ok) {
const after = r.state.players.find((p) => p.id === defender)!.position;
expect(cellKey(after)).not.toBe(cellKey(before));
} else {
expect(r.error).toMatch(/blocked/);
}
});
});
@@ -1,47 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
describe("expansion terrain", () => {
it("killer ooze burns on entry and can drop you on your face", () => {
@@ -174,16 +135,14 @@ describe("expansion terrain", () => {
const t = stepTarget(view, from, side);
if (t.kind === "step" && cellKey(t.to) === cellKey(open[0]!)) {
e.position = from;
const result = applyCommand(state, enemy.id, { type: "move", direction: side });
if (result.ok) {
state = result.state;
const hurt = state.players.find((p) => p.id === enemy.id)!;
expect(hurt.life).toBeLessThanOrEqual(11);
expect(state.boobytraps.length).toBe(0);
}
state = must(state, enemy.id, { type: "move", direction: side });
const hurt = state.players.find((p) => p.id === enemy.id)!;
expect(hurt.life).toBeLessThanOrEqual(11);
expect(state.boobytraps.length).toBe(0);
return;
}
}
throw new Error("setup: seed 42 offers no approach to the trap");
});
it("stone to water melts a stone block into a crashing wave", () => {
@@ -199,8 +158,6 @@ describe("expansion terrain", () => {
type: "cast", instanceId: stw.instanceId, target: { kind: "cell", cell: spot.cell },
});
expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined();
// The caster stood beside the block: the wave washed them somewhere (or
// crushed them for blocked spaces) — either way life or position changed
// is acceptable; assert no crash and the block is gone.
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
});
});
+2 -3
View File
@@ -218,9 +218,8 @@ describe("treasures and victory", () => {
const [t1, t2] = state.treasures.filter((t) => t.owner === enemy.id);
me.position = { ...t1!.position! };
const s1 = must(state, me.id, { type: "pickUpTreasure" });
const p = s1.players.find((p) => p.id === me.id)!;
p.position = { ...t2!.position! };
p; // actions ended by pickup — but even without that, a second pickup is illegal:
// Stand on the second treasure: the pickup is still refused ("one at a time").
s1.players.find((p) => p.id === me.id)!.position = { ...t2!.position! };
const result = applyCommand(s1, me.id, { type: "pickUpTreasure" });
expect(result.ok).toBe(false);
});
+78
View File
@@ -0,0 +1,78 @@
// Shared test rig: a deterministic two-wizard game plus the moves every
// suite makes — force a card into a hand, burn the no-combat first round,
// stand two wizards face to face, cast-and-let-resolve.
import {
applyCommand,
activePlayer,
boardView,
createGame,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
export function newGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
return createGame({ playerIds: players, seed, sets: ["basic"] });
}
export function newExpansionGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
return createGame({ playerIds: players, seed, sets: ["basic", "expansion1"] });
}
export function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
export function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
/** Burn the no-combat first round: both players pass their opening turn. */
export function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
/** Stand the other wizard on the active one's square. */
export function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
/** Cast at a player and let it resolve uncountered. */
export function castAt(
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
extra: Partial<Extract<Command, { type: "cast" }>> = {},
): GameState {
state = must(state, attacker, {
type: "cast", instanceId: card.instanceId,
target: { kind: "player", playerId: defender }, ...extra,
});
return must(state, defender, { type: "pass" });
}
/** An adjacent, walkable square holding no home, treasure, or wizard. */
export function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
@@ -1,55 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
sustainedOn,
viewFor,
type Command,
type GameState,
type PlayerId,
} from "../src";
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
describe("around the corner", () => {
it("bends line of sight past a wall that blocks a straight cast", () => {
@@ -138,15 +91,13 @@ describe("blind", () => {
});
expect(result.ok).toBe(true);
if (!result.ok) continue;
const dLife = result.state.players.find((p) => p.id === defender.id)!;
if (result.state.stack) {
hits++; // the roll matched: attack proceeds normally
} else {
misses++;
expect(result.state.turn.attackUsed).toBe(true); // card spent anyway
}
void dLife;
}
}
expect(hits + misses).toBe(10);
expect(misses).toBeGreaterThan(0);
});
@@ -230,7 +181,6 @@ describe("sector manipulation", () => {
);
const wallsBefore = Object.values(boardView(state).edges).filter((e) => e === "wall").length;
const homeBefore = { ...me.home };
const treasuresBefore = state.treasures.filter((t) => t.owner === me.id).map((t) => ({ ...t.position! }));
const rs = giveCard(state, me.id, "rotate-sector");
state = must(state, me.id, {
@@ -240,8 +190,7 @@ describe("sector manipulation", () => {
const after = state.players.find((p) => p.id === me.id)!;
// Home star is the exact center: rotation cannot move it.
expect(cellKey(after.home)).toBe(cellKey(homeBefore));
// The wizard stood on the home (center) at setup? They may have been
// anywhere; either way they remain inside the same sector.
// Wherever they stood, rotation keeps them inside the sector.
const p = state.board.placements[idx]!;
expect(after.position.x).toBeGreaterThanOrEqual(p.origin.x);
expect(after.position.x).toBeLessThan(p.origin.x + 5);
@@ -253,7 +202,6 @@ describe("sector manipulation", () => {
for (const t of state.treasures.filter((t) => t.owner === after.id)) {
expect(state.board.cells[cellKey(t.position!)]).toBe(true);
}
void treasuresBefore;
});
it("relocate sector slides everything and keeps adjacency", () => {
@@ -269,6 +217,7 @@ describe("sector manipulation", () => {
// Move my sector to the EAST side of the other sector (still adjacent).
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
const posBefore = { ...me.position };
const homeBefore = { ...me.home };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
@@ -277,7 +226,7 @@ describe("sector manipulation", () => {
const after = state.players.find((p) => p.id === me.id)!;
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
expect(cellKey(after.home)).toBe(cellKey({ x: after.home.x, y: after.home.y }));
expect(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy });
expect(state.board.placements[idx]!.origin).toEqual(dest);
// The map reassembled: every treasure/wizard cell exists on the new board.
for (const t of state.treasures) {
@@ -1,57 +1,7 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
displays,
handLimit,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
function castAt(
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
extra: Partial<Extract<Command, { type: "cast" }>> = {},
): GameState {
state = must(state, attacker, {
type: "cast", instanceId: card.instanceId,
target: { kind: "player", playerId: defender }, ...extra,
});
return must(state, defender, { type: "pass" });
}
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
/** Display a stone for a player during their turn. */
function displayStone(state: GameState, playerId: PlayerId, stoneId: string, slot = 0): GameState {
@@ -1,74 +1,10 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
import { cellKey, edgeKey, neighbor, SIDES } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
function castAt(
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
extra: Partial<Extract<Command, { type: "cast" }>> = {},
): GameState {
state = must(state, attacker, {
type: "cast", instanceId: card.instanceId,
target: { kind: "player", playerId: defender }, ...extra,
});
return must(state, defender, { type: "pass" });
}
import { newGame, must, giveCard, toRound2, faceOff, castAt, emptyNeighborCell } from "./helpers";
/** An empty visible cell adjacent to the player (not home, no treasure). */
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
describe("terrain", () => {
it("fill square with stone blocks movement and line of sight", () => {
let { state } = newGame();
+5 -42
View File
@@ -1,45 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView } from "../src/game";
import { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers";
describe("magic wands", () => {
it("blaster wand: charges on first use, once per turn, discards when spent", () => {
@@ -144,7 +107,7 @@ describe("magic wands", () => {
y: d.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.edges[k] === "wall" && view.cells[cellKey(dest)]) { through = dest; break; }
}
if (!through) return; // no adjacent wall on this seed; covered elsewhere
if (!through) throw new Error("setup: seed 42 lost its adjacent wall");
const wand = giveCard(state, attacker, "shift-wand");
giveCard(state, attacker, "number-2", "N", 1);
state = must(state, attacker, {
@@ -170,7 +133,7 @@ describe("magic wands", () => {
break;
}
}
if (!edge) return;
if (!edge) throw new Error("setup: seed 42 lost its adjacent wall");
const key = edgeKey(edge.cell, edge.side);
const wand = giveCard(state, me.id, "warp-wand");
giveCard(state, me.id, "number-2", "N", 1);
+2 -4
View File
@@ -5,8 +5,7 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@wizwar/engine": "*",
@@ -16,7 +15,6 @@
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
"typescript": "^5.6.0"
}
}
+15 -7
View File
@@ -1,14 +1,23 @@
// Websocket front door. Protocol (JSON messages):
// client -> server:
// {type:"create", name} create a room, become host
// {type:"join", roomId, name} join (or rejoin) a room
// {type:"start"} host starts the game
// {type:"join", roomId, name, token?} join, or reclaim a seat by token
// {type:"start", expansion?} host starts the game
// {type:"command", command} a game Command for the engine
// {type:"pickColor", color} lobby standee choice (0-5)
// {type:"makeTransfer"} mint a seat-transfer phrase
// {type:"claimTransfer", code} claim a seat on a new device
// {type:"catchUp", sinceSeq} replay of moves missed while away
// {type:"myGames", seats} summaries for held seats
// {type:"stats"} the engagement tally
// {type:"hotseatReport", ...} anonymous hotseat game counts
// server -> client:
// {type:"welcome"} on connect
// {type:"room", roomId, players, hostId, started}
// {type:"seat", playerId, token} your seat secret — keep it
// {type:"room", roomId, players, hostId, started, colors}
// {type:"events", events} redacted for this recipient
// {type:"state", view} redacted full view (after every change)
// {type:"state", view, seq} redacted full view (after every change)
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
// {type:"error", message}
import { createServer } from "node:http";
@@ -47,7 +56,6 @@ const NAME_MAX = 24;
/** Player/room names: printable, trimmed, bounded. */
function cleanName(raw: unknown): string {
// eslint-disable-next-line no-control-regex
return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, NAME_MAX);
}
@@ -109,7 +117,7 @@ const httpServer = createServer((req, res) => {
res.writeHead(500).end();
}
});
const wss = new WebSocketServer({ server: httpServer, path: undefined, maxPayload: 64 * 1024 });
const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 });
httpServer.listen(port, host);
interface Session {
@@ -163,7 +171,7 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
}
function broadcastRoomState(room: Room): void {
broadcast(room, (playerId) => roomInfo(room));
broadcast(room, () => roomInfo(room));
if (room.state) {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
}
+6 -99
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { attentionLabel, net } from "./net.svelte";
import Board from "./Board.svelte";
import { PLAYER_COLORS, wizardColor } from "./colors";
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import Replay from "./Replay.svelte";
@@ -23,7 +24,7 @@
let peekCard = $state<CardInstance | null>(null);
/** When the peeked card is a creature on the board, its live stats ride along. */
let peekCreatureId = $state<string | null>(null);
let helpTab = $state<"play" | "rules" | "cards" | "about">("play");
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
let hotseatCount = $state(2);
let setupName = $state("");
let setupColor = $state(0);
@@ -141,7 +142,6 @@
}
}
/** Map a typed card name to its id (case-insensitive). */
/** Suggest-as-you-type pool for the card-naming field, tuned per card. */
const nameSuggestions = $derived.by(() => {
if (!selectedCard) return [];
@@ -158,6 +158,7 @@
return selectedCard.cardId === "drop-object" ? ["Treasure", ...names] : names;
});
/** Map a typed card name to its id (case-insensitive). */
function nameToCardId(name: string): string | null {
const wanted = name.trim().toLowerCase();
if (!wanted) return null;
@@ -653,11 +654,8 @@
return `${Math.round(s / 86400)} d ago`;
}
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
function playerColor(id: string): string {
const p = view?.players.find((p) => p.id === id);
const idx = p?.colorIndex ?? (view?.players.findIndex((q) => q.id === id) ?? 0);
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
return view ? wizardColor(view, id) : PLAYER_COLORS[0]!;
}
</script>
@@ -1862,98 +1860,6 @@
line-height: 1;
cursor: pointer;
}
@media (max-width: 900px) {
.attack-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.72);
display: grid;
place-items: center;
z-index: 45;
padding: 1rem;
}
.attack-notice {
background: #efe8d4;
border: 2px solid #43331f;
border-radius: 8px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
padding: 1.2rem 1.6rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
max-width: min(22rem, 92vw);
text-align: center;
}
.attack-headline {
font-family: "Oswald", sans-serif;
font-size: 1.15rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #b3372b;
}
.attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
.attack-card :global(.card:hover) { transform: scale(1.35); }
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; }
.peek-note {
margin-top: 0.4rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Courier Prime", monospace;
font-size: 0.72rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
text-align: center;
max-width: 11rem;
}
.final-reveal {
background: #efe8d4;
border: 1px solid #b3a687;
border-radius: 6px;
padding: 0.7rem 0.9rem;
margin-bottom: 0.6rem;
}
.reveal-head {
font-family: "Oswald", sans-serif;
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #43331f;
border-bottom: 1px solid #b3a687;
padding-bottom: 0.2rem;
margin-bottom: 0.5rem;
}
.reveal-row { display: flex; align-items: flex-start; gap: 0.6rem; margin-bottom: 0.5rem; }
.reveal-name {
font-family: "Courier Prime", monospace;
font-size: 0.8rem;
color: #43331f;
min-width: 6.5rem;
padding-top: 0.4rem;
}
.reveal-name.reveal-winner { font-weight: 700; }
.reveal-note { display: block; font-family: "Caveat", cursive; font-size: 0.85rem; color: #8a7a5e; }
.reveal-cards { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.reveal-cards :global(.card) { transform: scale(0.82); transform-origin: top left; margin: -0.35rem -0.8rem -1.1rem 0; }
.reveal-empty { font-family: "Caveat", cursive; color: #8a7a5e; padding-top: 0.5rem; }
.victory-notice { border-color: #a5842c; box-shadow: 0 0 0 4px rgba(201, 167, 42, 0.35), 0 18px 50px rgba(0, 0, 0, 0.6); }
.victory-trophy { font-size: 3rem; line-height: 1; }
.victory-name {
font-family: "Oswald", sans-serif;
font-size: 1.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #43331f;
}
.victory-how { font-family: "Caveat", cursive; font-size: 1.25rem; color: #6b5a41; }
.inspector { right: 0.5rem; bottom: 0.5rem; }
.inspector-card { transform: scale(1.3); }
}
.hand {
display: flex;
@@ -1968,6 +1874,8 @@
/* One-column phone flow: board, then your controls and hand, THEN the
paperwork — cards must never hide below the chronicle. */
.game { display: contents; }
.inspector { right: 0.5rem; bottom: 0.5rem; }
.inspector-card { transform: scale(1.3); }
.board-zone { order: 1; }
.table-edge { order: 2; margin-top: 0.6rem; }
.paper-rail { order: 3; margin-top: 0.9rem; }
@@ -1984,6 +1892,5 @@
.mast-title { font-size: 1.05rem; white-space: nowrap; }
.mast-sub { display: none; }
.mast-leave { font-size: 0.72rem; }
.hand { min-height: auto; }
}
</style>
+5 -21
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { GameView } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
const CELL = 48;
const WALL = 7;
@@ -34,7 +35,6 @@
litCells?: Set<string> | null;
} = $props();
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
// Press-and-hold peeks the square's card; a fired hold swallows the click.
let peekTimer: ReturnType<typeof setTimeout> | null = null;
@@ -81,18 +81,14 @@
return 1;
}
function wizardArt(id: string): string {
return `/tokens/wizard-${colorIndexOf(id) % 6}.png`;
return `/tokens/wizard-${sharedColorIndex(view, id) % 6}.png`;
}
function treasureArt(owner: string): string {
return `/tokens/treasure-${colorIndexOf(owner) % 6}.png`;
return `/tokens/treasure-${sharedColorIndex(view, owner) % 6}.png`;
}
function colorIndexOf(id: string): number {
const p = view.players.find((p) => p.id === id);
return p?.colorIndex ?? Math.max(0, view.players.findIndex((q) => q.id === id));
}
function playerColor(id: string): string {
return PLAYER_COLORS[colorIndexOf(id) % PLAYER_COLORS.length]!;
return wizardColor(view, id);
}
const cells = $derived(
@@ -599,19 +595,7 @@
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.dim-cell {
fill: rgba(12, 9, 5, 0.55);
pointer-events: none;
}
.marked-cell {
fill: rgba(211, 133, 43, 0.18);
stroke: #d3852b;
stroke-width: 2.5;
stroke-dasharray: 7 4;
pointer-events: none;
animation: warp-pulse 1.6s ease-in-out infinite;
}
.warp-dest { animation: none; }
.marked-cell, .warp-dest { animation: none; }
}
.wizard { cursor: pointer; }
.wizard-label {
+13
View File
@@ -0,0 +1,13 @@
// The six physical standee colors, in colorIndex order.
import type { GameView } from "@wizwar/engine";
export const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
export function colorIndexOf(view: GameView, id: string): number {
const p = view.players.find((p) => p.id === id);
return p?.colorIndex ?? Math.max(0, view.players.findIndex((q) => q.id === id));
}
export function wizardColor(view: GameView, id: string): string {
return PLAYER_COLORS[colorIndexOf(view, id) % PLAYER_COLORS.length]!;
}
+1 -1
View File
@@ -21,7 +21,7 @@ const SAVE_KEY = "wizwar-hotseat";
interface SavedHotseat {
config: GameConfig;
commands: { playerId: PlayerId; command: Command }[];
/** Anonymous tally identity + table-time bookkeeping (added later; optional). */
/** Anonymous tally identity + table-time bookkeeping; absent in older saves. */
tallyId?: string;
activeMs?: number;
lastMoveAt?: number;