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:
co-authored by
Claude Fable 5
parent
f62fcf2510
commit
695307daa8
@@ -4,3 +4,4 @@ dist/
|
||||
.env
|
||||
.DS_Store
|
||||
data/
|
||||
.claude/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-54
@@ -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 });
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+9
-52
@@ -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.
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+7
-58
@@ -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 {
|
||||
+3
-67
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user