Credibility pass: the splice marks sanded from the rev-11 batch
The extracted rebuildRoom loop reflowed to the file's indent and its doc comments unstacked; liftIdiotIfSatisfied moves below idiotBlocked so each keeps its own comment; the pickup's duplicated owner lookup folds to one; the cloned grab-refusal chain hoists to grabRefused; a one-off #8a7a5c rejoins the #8a7a5e palette; an inert overflow-x line and a duplicate import go; span 6 and the two cache strategies say why; the reversed walking-dead test moves beside its biting sibling. Also: the replay harness now honors lobby kicks, which VVZU (five bots seated, two kicked) was the first production ledger to exercise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
e83d70116f
commit
a1fdb37c54
@@ -8,8 +8,14 @@ if (!start) {
|
|||||||
console.log(`OK ${process.argv[2].split("/").pop()}: lobby only, nothing to replay`);
|
console.log(`OK ${process.argv[2].split("/").pop()}: lobby only, nothing to replay`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
const joins = lines.filter((l) => l.kind === "join");
|
// The lobby roster: joins add seats, kicks remove them, the start freezes it.
|
||||||
const playerIds = [...new Set([meta.hostId, ...joins.map((j) => j.name)])];
|
const roster = new Set([meta.hostId]);
|
||||||
|
for (const l of lines) {
|
||||||
|
if (l.kind === "join") roster.add(l.name);
|
||||||
|
if (l.kind === "kick") roster.delete(l.name);
|
||||||
|
if (l.kind === "start") break;
|
||||||
|
}
|
||||||
|
const playerIds = [...roster];
|
||||||
let { state } = createGame({ playerIds, seed: meta.seed, sets: start.expansion ? ["basic", "expansion1"] : ["basic"], colors: start.colors, deckRev: start.deckRev });
|
let { state } = createGame({ playerIds, seed: meta.seed, sets: start.expansion ? ["basic", "expansion1"] : ["basic"], colors: start.colors, deckRev: start.deckRev });
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const l of lines) {
|
for (const l of lines) {
|
||||||
|
|||||||
@@ -4568,6 +4568,13 @@ function castingBlocked(
|
|||||||
* treasure on their own home, or drop their own treasure underfoot for an
|
* treasure on their own home, or drop their own treasure underfoot for an
|
||||||
* instant cure.
|
* instant cure.
|
||||||
*/
|
*/
|
||||||
|
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||||
|
if (sustainedOn(state, playerId, "idiot").length > 0) {
|
||||||
|
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** IDIOT lifts when the march is done — the victim stands on their own
|
/** IDIOT lifts when the march is done — the victim stands on their own
|
||||||
* treasure — or turns unsatisfiable: every one of their treasures is in
|
* treasure — or turns unsatisfiable: every one of their treasures is in
|
||||||
* someone's arms, leaving nothing to stand on. */
|
* someone's arms, leaving nothing to stand on. */
|
||||||
@@ -4584,13 +4591,6 @@ function liftIdiotIfSatisfied(state: GameState, events: GameEvent[], p: PlayerSt
|
|||||||
state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id));
|
state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
|
|
||||||
if (sustainedOn(state, playerId, "idiot").length > 0) {
|
|
||||||
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
|
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
|
||||||
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
||||||
if (sustainedOn(state, target.id, "big-man").length > 0 &&
|
if (sustainedOn(state, target.id, "big-man").length > 0 &&
|
||||||
@@ -6423,13 +6423,12 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
|
|||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
|
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
|
||||||
];
|
];
|
||||||
|
const owner = state.players.find((q) => q.id === t.owner);
|
||||||
// This grab may have lifted the owner's IDIOT: with every one of their
|
// This grab may have lifted the owner's IDIOT: with every one of their
|
||||||
// treasures now carried, there is nothing left to march to.
|
// treasures now carried, there is nothing left to march to.
|
||||||
const cursedOwner = state.players.find((q) => q.id === t.owner);
|
if (owner) liftIdiotIfSatisfied(state, events, owner);
|
||||||
if (cursedOwner) liftIdiotIfSatisfied(state, events, cursedOwner);
|
|
||||||
// WARD: "you may play at that time (out of turn) this card on him" —
|
// WARD: "you may play at that time (out of turn) this card on him" —
|
||||||
// literally: the grab hangs while the owner decides.
|
// literally: the grab hangs while the owner decides.
|
||||||
const owner = state.players.find((q) => q.id === t.owner);
|
|
||||||
if (owner && owner.alive && owner.id !== p.id) {
|
if (owner && owner.alive && owner.id !== p.id) {
|
||||||
const holdsWard = owner.hand.some((c) => c.cardId === "ward");
|
const holdsWard = owner.hand.some((c) => c.cardId === "ward");
|
||||||
if (holdsWard) {
|
if (holdsWard) {
|
||||||
|
|||||||
@@ -412,6 +412,8 @@ describe("big man", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("a shrunk wizard slips between the giant's boots", () => {
|
it("a shrunk wizard slips between the giant's boots", () => {
|
||||||
|
// bigRig() makes the ACTIVE player the giant; here the mover must act,
|
||||||
|
// so the giant takes the other seat.
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const mover = activePlayer(state);
|
const mover = activePlayer(state);
|
||||||
|
|||||||
@@ -58,6 +58,32 @@ describe("expansion combat cards", () => {
|
|||||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a reversed walking dead heals half a point per space walked (rev 11)", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const wd = giveCard(state, attacker, "walking-dead");
|
||||||
|
giveCard(state, defender, "reverse", "RV", 0);
|
||||||
|
const r = applyCommand(state, attacker, {
|
||||||
|
type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender },
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
|
||||||
|
state = drain(state);
|
||||||
|
expect(sustainedOn(state, defender, "walking-dead").length).toBe(1);
|
||||||
|
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
// Two spaces walked: one point gained, not bled.
|
||||||
|
let steps = 0;
|
||||||
|
for (const dir of ["N", "S", "E", "W", "N", "S", "E", "W"] as const) {
|
||||||
|
if (steps >= 2) break;
|
||||||
|
const mv = applyCommand(state, defender, { type: "move", direction: dir });
|
||||||
|
if (mv.ok) { state = mv.state; steps++; }
|
||||||
|
}
|
||||||
|
expect(steps).toBe(2);
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
it("mental swap trades entire hands", () => {
|
it("mental swap trades entire hands", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
|
import { applyCommand, activePlayer, createGame, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { createGame } from "../src/game";
|
|
||||||
import { newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
import { newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||||
|
|
||||||
/** Display a stone for a player during their turn. */
|
/** Display a stone for a player during their turn. */
|
||||||
@@ -170,31 +169,6 @@ describe("slow death", () => {
|
|||||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(17);
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(17);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a reversed walking dead heals half a point per space walked (rev 11)", () => {
|
|
||||||
let { state } = newGame();
|
|
||||||
state = toRound2(state);
|
|
||||||
const { attacker, defender } = faceOff(state);
|
|
||||||
const wd = giveCard(state, attacker, "walking-dead");
|
|
||||||
giveCard(state, defender, "reverse", "RV", 0);
|
|
||||||
const r = applyCommand(state, attacker, {
|
|
||||||
type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender },
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error(r.error);
|
|
||||||
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
|
|
||||||
state = drain(state);
|
|
||||||
expect(sustainedOn(state, defender, "walking-dead").length).toBe(1);
|
|
||||||
|
|
||||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
||||||
// Two spaces walked: one point gained, not bled.
|
|
||||||
let steps = 0;
|
|
||||||
for (const dir of ["N", "S", "E", "W", "N", "S", "E", "W"] as const) {
|
|
||||||
if (steps >= 2) break;
|
|
||||||
const mv = applyCommand(state, defender, { type: "move", direction: dir });
|
|
||||||
if (mv.ok) { state = mv.state; steps++; }
|
|
||||||
}
|
|
||||||
expect(steps).toBe(2);
|
|
||||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(16);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a full reflection returns the curse onto its caster (rev 11)", () => {
|
it("a full reflection returns the curse onto its caster (rev 11)", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
|
|||||||
@@ -225,7 +225,10 @@ function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
|
|||||||
];
|
];
|
||||||
return ogPage(metas);
|
return ogPage(metas);
|
||||||
}
|
}
|
||||||
/** Rendered share cards, by share id (immutable once minted). */
|
|
||||||
|
/** Rendered share cards, by share id. Shares are immutable, so unlike the
|
||||||
|
* TTL'd shareCache above this never invalidates — it only rotates out the
|
||||||
|
* oldest entry when full. */
|
||||||
const ogPngCache = new Map<string, Buffer>();
|
const ogPngCache = new Map<string, Buffer>();
|
||||||
const OG_PNG_CACHE_MAX = 200;
|
const OG_PNG_CACHE_MAX = 200;
|
||||||
|
|
||||||
|
|||||||
@@ -651,7 +651,6 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
|
|||||||
return { roomId: t.roomId, name: t.name, token: t.token };
|
return { roomId: t.roomId, name: t.name, token: t.token };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild every persisted room by replaying its file. */
|
|
||||||
/** Rebuild one room from its ledger lines — the seed plus the log IS the
|
/** Rebuild one room from its ledger lines — the seed plus the log IS the
|
||||||
* game. Returns null for an abandoned room; throws on a corrupt ledger. */
|
* game. Returns null for an abandoned room; throws on a corrupt ledger. */
|
||||||
function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||||
@@ -676,46 +675,47 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
|||||||
};
|
};
|
||||||
if (lines.some((l) => l.kind === "abandon")) return null;
|
if (lines.some((l) => l.kind === "abandon")) return null;
|
||||||
for (const line of lines.slice(1)) {
|
for (const line of lines.slice(1)) {
|
||||||
if (line.kind === "kick") {
|
if (line.kind === "kick") {
|
||||||
room.players = room.players.filter((p) => p !== line.name);
|
room.players = room.players.filter((p) => p !== line.name);
|
||||||
room.tokens.delete(line.name);
|
room.tokens.delete(line.name);
|
||||||
room.bots.delete(line.name);
|
room.bots.delete(line.name);
|
||||||
room.colorChoices.delete(line.name);
|
room.colorChoices.delete(line.name);
|
||||||
} else if (line.kind === "join") {
|
} else if (line.kind === "join") {
|
||||||
if (line.bot) {
|
if (line.bot) {
|
||||||
room.players.push(line.name);
|
room.players.push(line.name);
|
||||||
room.bots.set(line.name, {
|
room.bots.set(line.name, {
|
||||||
style: (line.style as AutomatonStyle) ?? "hunter",
|
style: (line.style as AutomatonStyle) ?? "hunter",
|
||||||
secret: line.secret === true,
|
secret: line.secret === true,
|
||||||
// Tier-less join lines predate tiers, when every automaton
|
// Tier-less join lines predate tiers, when every automaton
|
||||||
// played the full repertoire — not the "adept" lobby default.
|
// played the full repertoire — not the "adept" lobby default.
|
||||||
tier: (line.tier as AutomatonTier) ?? "archmage",
|
tier: (line.tier as AutomatonTier) ?? "archmage",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
|
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
|
||||||
if (!joinHash) throw new Error("join line has no token");
|
if (!joinHash) throw new Error("join line has no token");
|
||||||
room.players.push(line.name);
|
room.players.push(line.name);
|
||||||
room.tokens.set(line.name, joinHash);
|
room.tokens.set(line.name, joinHash);
|
||||||
}
|
}
|
||||||
} else if (line.kind === "start") {
|
} else if (line.kind === "start") {
|
||||||
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
|
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
|
||||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||||
} else if (line.kind === "chat") {
|
} else if (line.kind === "chat") {
|
||||||
// File order preserves the interleaving with commands.
|
// File order preserves the interleaving with commands.
|
||||||
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
||||||
room.events.push({ type: "tableTalk", player: line.player, text: line.text });
|
room.events.push({ type: "tableTalk", player: line.player, text: line.text });
|
||||||
} else if (line.kind === "command") {
|
} else if (line.kind === "command") {
|
||||||
if (!room.state) throw new Error("command before start in log");
|
if (!room.state) throw new Error("command before start in log");
|
||||||
const result = applyCommand(room.state, line.playerId, line.command as Command);
|
const result = applyCommand(room.state, line.playerId, line.command as Command);
|
||||||
if (!result.ok) throw new Error(`replay failed at seq ${line.seq}: ${result.error}`);
|
if (!result.ok) throw new Error(`replay failed at seq ${line.seq}: ${result.error}`);
|
||||||
room.state = result.state;
|
room.state = result.state;
|
||||||
room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at });
|
room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at });
|
||||||
room.events.push(...result.events);
|
room.events.push(...result.events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rebuild every persisted room by replaying its file. */
|
||||||
export function loadPersistedRooms(): void {
|
export function loadPersistedRooms(): void {
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
let restored = 0;
|
let restored = 0;
|
||||||
|
|||||||
@@ -102,8 +102,8 @@
|
|||||||
}
|
}
|
||||||
/** Board zoom: 1 = fitted to the viewport; higher magnifies for crowded
|
/** Board zoom: 1 = fitted to the viewport; higher magnifies for crowded
|
||||||
* squares, with the viewport scrolling. The svg's width is always set
|
* squares, with the viewport scrolling. The svg's width is always set
|
||||||
* explicitly (fit × zoom) from measurement — auto-sizing left stale
|
* explicitly (fit × zoom) from measurement — auto-sizing leaves stale
|
||||||
* dimensions behind when the first-person pane closed. */
|
* dimensions behind when the first-person pane closes. */
|
||||||
let boardViewportEl = $state<HTMLDivElement | null>(null);
|
let boardViewportEl = $state<HTMLDivElement | null>(null);
|
||||||
let boardZoom = $state(1);
|
let boardZoom = $state(1);
|
||||||
function zoomBoard(dir: number) {
|
function zoomBoard(dir: number) {
|
||||||
@@ -1079,16 +1079,14 @@
|
|||||||
const weakened = view.sustained.some((e) => e.cardId === "weakness" && e.targetId === view.you);
|
const weakened = view.sustained.some((e) => e.cardId === "weakness" && e.targetId === view.you);
|
||||||
const safed = view.squareContents[here]?.kind === "safe" &&
|
const safed = view.squareContents[here]?.kind === "safe" &&
|
||||||
view.squareContents[here]!.createdBy !== view.you && !view.openSafes.includes(here);
|
view.squareContents[here]!.createdBy !== view.you && !view.openSafes.includes(here);
|
||||||
if (treasuresHere.length > 0 && !autoWillGrab && !me.carriedTreasureId &&
|
const grabRefused = !!me.carriedTreasureId || view.turn.actionsEnded || weakened ||
|
||||||
!view.turn.actionsEnded && !weakened && !view.gluedCells[here] && !safed &&
|
!!view.gluedCells[here] || safed || here === cellKey(me.home);
|
||||||
here !== cellKey(me.home)) {
|
if (treasuresHere.length > 0 && !autoWillGrab && !grabRefused) {
|
||||||
reasons.push(`a treasure lies at your feet, un-grabbed`);
|
reasons.push(`a treasure lies at your feet, un-grabbed`);
|
||||||
}
|
}
|
||||||
// Auto-grab about to pocket your OWN chest: carrying it does not
|
// Auto-grab about to pocket your OWN chest: carrying it does not
|
||||||
// score and bares it to capture, so that walk-off asks first.
|
// score and bares it to capture, so that walk-off asks first.
|
||||||
if (autoWillGrab && !me.carriedTreasureId && !view.turn.actionsEnded &&
|
if (autoWillGrab && !grabRefused && treasuresHere[0]!.owner === view.you) {
|
||||||
!weakened && !view.gluedCells[here] && !safed && here !== cellKey(me.home) &&
|
|
||||||
treasuresHere[0]!.owner === view.you) {
|
|
||||||
reasons.push("your OWN treasure lies underfoot — ending the turn grabs it and carries it along");
|
reasons.push("your OWN treasure lies underfoot — ending the turn grabs it and carries it along");
|
||||||
}
|
}
|
||||||
// The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves.
|
// The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves.
|
||||||
@@ -3053,6 +3051,8 @@
|
|||||||
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
|
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
|
||||||
.game.hand-left .table-edge > .hand {
|
.game.hand-left .table-edge > .hand {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
|
/* One implicit row per table-edge sibling; the strip deals at
|
||||||
|
* most six, and the hand must span them all to hold the column. */
|
||||||
grid-row: 1 / span 6;
|
grid-row: 1 / span 6;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 8.2rem);
|
grid-template-columns: repeat(2, 8.2rem);
|
||||||
@@ -3063,7 +3063,6 @@
|
|||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
max-height: calc(100dvh - 5rem);
|
max-height: calc(100dvh - 5rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: visible;
|
|
||||||
padding: 0.3rem 0.3rem 0.9rem;
|
padding: 0.3rem 0.3rem 0.9rem;
|
||||||
}
|
}
|
||||||
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
|
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
|
||||||
@@ -3120,7 +3119,7 @@
|
|||||||
.zoom-btn {
|
.zoom-btn {
|
||||||
width: 1.7rem;
|
width: 1.7rem;
|
||||||
height: 1.7rem;
|
height: 1.7rem;
|
||||||
border: 1px solid #8a7a5c;
|
border: 1px solid #8a7a5e;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(233, 225, 203, 0.9);
|
background: rgba(233, 225, 203, 0.9);
|
||||||
color: #43331f;
|
color: #43331f;
|
||||||
|
|||||||
Reference in New Issue
Block a user