Two-player decks shed LIFESAVER, as the card itself instructs
"Not applicable in a 2-player game." — the card face. New games deal from deck revision 2, which removes it when exactly two wizards sit down; three or more keep it. The revision travels in GameConfig and the persisted start line, and stored games without one replay against the original build — changing an existing game's deck composition would scramble its deterministic replay. Test pins all three cases: removed at two, present at three, present in legacy two-player games. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
26c4b6efdf
commit
efec82e88d
@@ -200,6 +200,12 @@ export interface GameConfig {
|
|||||||
* standees: green, red, magenta, blue, light blue, yellow). Defaults to
|
* standees: green, red, magenta, blue, light blue, yellow). Defaults to
|
||||||
* seat order. */
|
* seat order. */
|
||||||
colors?: number[];
|
colors?: number[];
|
||||||
|
/**
|
||||||
|
* Deck revision. Absent = the original build, which stored games replay
|
||||||
|
* against forever. Revision 2 removes LIFESAVER from two-player decks
|
||||||
|
* ("Not applicable in a 2-player game." — the card face).
|
||||||
|
*/
|
||||||
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
@@ -2895,7 +2901,11 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [deckShuffled, rng3] = shuffle(rng, buildDeck(config.sets));
|
let fullDeck = buildDeck(config.sets);
|
||||||
|
if ((config.deckRev ?? 1) >= 2 && n === 2) {
|
||||||
|
fullDeck = fullDeck.filter((c) => c.cardId !== "lifesaver");
|
||||||
|
}
|
||||||
|
const [deckShuffled, rng3] = shuffle(rng, fullDeck);
|
||||||
rng = rng3;
|
rng = rng3;
|
||||||
const deck = [...deckShuffled];
|
const deck = [...deckShuffled];
|
||||||
const discard: CardInstance[] = [];
|
const discard: CardInstance[] = [];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyCommand, activePlayer, boardView } from "../src/game";
|
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
|
||||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board";
|
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||||
@@ -314,3 +314,17 @@ describe("stored-log compatibility", () => {
|
|||||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(15 - 5); // fireball: 2 + the 3
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15 - 5); // fireball: 2 + the 3
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("deck revisions", () => {
|
||||||
|
it("revision 2 removes LIFESAVER at two players, and only there", () => {
|
||||||
|
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
||||||
|
const inGame = (s: typeof two.state) =>
|
||||||
|
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
|
||||||
|
expect(inGame(two.state)).toBe(false);
|
||||||
|
// Three players keep it; old two-player games (no deckRev) keep it too.
|
||||||
|
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
||||||
|
expect(inGame(three.state)).toBe(true);
|
||||||
|
const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
||||||
|
expect(inGame(legacy.state)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function pickColor(room: Room, playerId: PlayerId, color: number): string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startInMemory(room: Room, expansion: boolean, colors?: number[]): { events: GameEvent[] } | { error: string } {
|
function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRev?: number): { events: GameEvent[] } | { error: string } {
|
||||||
const n = room.players.length;
|
const n = room.players.length;
|
||||||
if (n < 2 || n > 6) return { error: "supported player counts: 2 to 6" };
|
if (n < 2 || n > 6) return { error: "supported player counts: 2 to 6" };
|
||||||
const { state, events } = createGame({
|
const { state, events } = createGame({
|
||||||
@@ -165,6 +165,7 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[]): { eve
|
|||||||
seed: room.seed,
|
seed: room.seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
...(colors ? { colors } : {}),
|
...(colors ? { colors } : {}),
|
||||||
|
...(deckRev ? { deckRev } : {}),
|
||||||
});
|
});
|
||||||
room.expansion = expansion;
|
room.expansion = expansion;
|
||||||
room.state = state;
|
room.state = state;
|
||||||
@@ -175,9 +176,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[]): { eve
|
|||||||
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
||||||
if (room.state) return { error: "already started" };
|
if (room.state) return { error: "already started" };
|
||||||
const colors = resolveColors(room);
|
const colors = resolveColors(room);
|
||||||
const result = startInMemory(room, expansion, colors);
|
const result = startInMemory(room, expansion, colors, 2);
|
||||||
if ("error" in result) return result;
|
if ("error" in result) return result;
|
||||||
appendLine(room.id, { kind: "start", expansion, colors });
|
appendLine(room.id, { kind: "start", expansion, colors, deckRev: 2 });
|
||||||
recordRoom(room);
|
recordRoom(room);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -396,7 +397,7 @@ export function loadPersistedRooms(): void {
|
|||||||
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);
|
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 === "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");
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface StartLine {
|
|||||||
expansion: boolean;
|
expansion: boolean;
|
||||||
/** Final wizard colors, in player join order. */
|
/** Final wizard colors, in player join order. */
|
||||||
colors?: number[];
|
colors?: number[];
|
||||||
|
/** Deck revision the game was dealt from; absent = original build. */
|
||||||
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandLine {
|
export interface CommandLine {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class LocalGame {
|
|||||||
seed,
|
seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
...(colors ? { colors } : {}),
|
...(colors ? { colors } : {}),
|
||||||
|
deckRev: 2,
|
||||||
};
|
};
|
||||||
const { state, events } = createGame(config);
|
const { state, events } = createGame(config);
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|||||||
Reference in New Issue
Block a user