diff --git a/.gitignore b/.gitignore index e5c4c5d..86c783f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .env .DS_Store data/ +.claude/ diff --git a/deploy/Caddyfile b/deploy/Caddyfile deleted file mode 100644 index cebddb1..0000000 --- a/deploy/Caddyfile +++ /dev/null @@ -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..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 diff --git a/deploy/README.md b/deploy/README.md index 4515a76..6f1542d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index f04c3dc..c1646fe 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -5,7 +5,7 @@ HOST="${1:?usage: deploy.sh }" 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 diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh index a18a8a0..7608d31 100755 --- a/deploy/setup-droplet.sh +++ b/deploy/setup-droplet.sh @@ -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 diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index eb8a90c..d0ccc68 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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 = { 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 { const blockers: Record = {}; 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 = {}; - 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 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 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 }, }, // --- 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 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 // "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 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 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 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 // "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 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): 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(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): 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): 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): 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): 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. diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 3c10e71..28b488f 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -157,7 +157,6 @@ export function sightedCellsFor(view: GameView): Set { 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; diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 32caf97..570beeb 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -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 + }); +}); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 721c4e4..40287c8 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -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); diff --git a/packages/engine/test/wave2.test.ts b/packages/engine/test/durations-doors-cards.test.ts similarity index 89% rename from packages/engine/test/wave2.test.ts rename to packages/engine/test/durations-doors-cards.test.ts index 2821b03..bbab4ae 100644 --- a/packages/engine/test/wave2.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -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> = {}, -): 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 }); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 97adee7..0adfa0b 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -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> = {}, -): 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/); } }); }); diff --git a/packages/engine/test/terrain2.test.ts b/packages/engine/test/expansion-terrain.test.ts similarity index 77% rename from packages/engine/test/terrain2.test.ts rename to packages/engine/test/expansion-terrain.test.ts index f029ce5..a6c4ad0 100644 --- a/packages/engine/test/terrain2.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -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. }); }); diff --git a/packages/engine/test/game.test.ts b/packages/engine/test/game.test.ts index 83bb304..cff2420 100644 --- a/packages/engine/test/game.test.ts +++ b/packages/engine/test/game.test.ts @@ -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); }); diff --git a/packages/engine/test/helpers.ts b/packages/engine/test/helpers.ts new file mode 100644 index 0000000..6f3dd20 --- /dev/null +++ b/packages/engine/test/helpers.ts @@ -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> = {}, +): 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"); +} diff --git a/packages/engine/test/wave5.test.ts b/packages/engine/test/sight-illusions-sectors.test.ts similarity index 87% rename from packages/engine/test/wave5.test.ts rename to packages/engine/test/sight-illusions-sectors.test.ts index 31549b6..bcbae70 100644 --- a/packages/engine/test/wave5.test.ts +++ b/packages/engine/test/sight-illusions-sectors.test.ts @@ -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) { diff --git a/packages/engine/test/wave4.test.ts b/packages/engine/test/stones.test.ts similarity index 82% rename from packages/engine/test/wave4.test.ts rename to packages/engine/test/stones.test.ts index ffe394a..ccd0fb0 100644 --- a/packages/engine/test/wave4.test.ts +++ b/packages/engine/test/stones.test.ts @@ -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> = {}, -): 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 { diff --git a/packages/engine/test/wave3.test.ts b/packages/engine/test/terrain-objects-control.test.ts similarity index 84% rename from packages/engine/test/wave3.test.ts rename to packages/engine/test/terrain-objects-control.test.ts index 66a7f36..a56efd4 100644 --- a/packages/engine/test/wave3.test.ts +++ b/packages/engine/test/terrain-objects-control.test.ts @@ -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> = {}, -): 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(); diff --git a/packages/engine/test/wands.test.ts b/packages/engine/test/wands.test.ts index 2d9d808..193bc0b 100644 --- a/packages/engine/test/wands.test.ts +++ b/packages/engine/test/wands.test.ts @@ -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); diff --git a/packages/server/package.json b/packages/server/package.json index a7644d8..3e30b95 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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" } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 60d9868..8e6d4f4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 })); } diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 78da005..c6f943e 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,6 +1,7 @@ @@ -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; } } diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 3bf1e7f..57a6f7d 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -1,6 +1,7 @@