Files
Dune/tests/leader-skills-recovery.test.ts

556 lines
18 KiB
TypeScript

import test from 'node:test';
import { mock } from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import {
LEADER_SKILL_CARDS,
type LeaderSkillId,
} from '../game/leader-skill-cards';
import {
initializeLeaderSkillsGameForAudit,
type Action,
type Game,
} from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function assertSkillCustody(game: Game): void {
assert.ok(game.leaderSkills);
const physical = [
...game.leaderSkills.deck,
...game.leaderSkills.assignments.map((assignment) => assignment.skill),
...Object.values(game.leaderSkills.offers).flatMap((offer) => offer.cards),
];
const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
assert.equal(physical.length, 14);
assert.equal(new Set(physical).size, 14);
assert.deepEqual([...physical].sort(), [...canonical].sort());
}
function concurrentBarrier() {
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
return {
wait: async () => {
arrivals += 1;
if (arrivals === 2) release();
await gate;
},
count: () => arrivals,
};
}
async function fixture(firstSkill?: LeaderSkillId) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
'Leader Skills host',
'beneGesserit',
false,
[],
);
const code = created.view.code;
const tokens = [created.token!];
for (const [name, faction] of [
['Fremen seat', 'fremen'],
['Atreides seat', 'atreides'],
] as const)
tokens.push((await store.rooms.joinRoom(code, name, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.restart().authenticate(code, token)),
);
for (const auth of auths) {
const room = await store.restart().readRoom(code);
await store
.restart()
.act(code, auth, room.version, { type: 'ready' }, clock);
}
const ready = await store.restart().readRoom(code);
const targetIndex = firstSkill
? LEADER_SKILL_CARDS.findIndex((card) => card.id === firstSkill)
: -1;
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
if (firstSkill)
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
let initialized: Game;
try {
initialized = initializeLeaderSkillsGameForAudit(ready);
} finally {
mock.restoreAll();
}
if (firstSkill) assert.equal(initialized.leaderSkills!.deck[0], firstSkill);
sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(initialized), initialized.version, code);
return { ...store, sqlite, code, tokens, auths };
}
async function act(
f: Awaited<ReturnType<typeof fixture>>,
player: number,
action: Action,
) {
const before = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.auths[player], before.version, action, clock);
return f.restart().readRoom(f.code);
}
void test('SQLite restart keeps each private two-card offer and one concurrent selection consumes one card once', async () => {
const f = await fixture();
try {
let game = await f.restart().readRoom(f.code);
assert.equal(game.status, 'setup');
assert.equal(game.setupStage, 'prediction');
game = await act(f, 0, {
type: 'predict',
faction: 'atreides',
turn: 4,
});
assert.equal(game.setupStage, 'leaderSkills');
assertSkillCustody(game);
const initialOffers = structuredClone(game.leaderSkills!.offers);
assert.equal(Object.keys(initialOffers).length, 3);
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
const owner = auth.playerId;
assert.deepEqual(seat.leaderSkills?.offer, initialOffers[owner]);
assert.equal(seat.leaderSkills?.assignments.length, 0);
assert.equal(seat.leaderSkills?.eligibleLeaders.length, 5);
for (const other of f.auths.filter((candidate) => candidate !== auth))
assert.notDeepEqual(
seat.leaderSkills?.offer?.cards,
initialOffers[other.playerId].cards,
);
assert.equal('deck' in seat.leaderSkills!, false);
assert.equal(seat.players[index].hand?.length, 1);
}
const owner = f.auths[0];
const ownerView = await f.restart().readSeatView(f.code, owner);
const offer = ownerView.leaderSkills!.offer!;
const chosenSkill = offer.cards[0];
const returnedSkill = offer.cards[1];
const chosenLeader = ownerView.leaderSkills!.eligibleLeaders[0].id;
const choice: Action = {
type: 'leaderSkill',
event: offer.event,
skill: chosenSkill,
leader: chosenLeader,
};
const beforeVersion = game.version;
const barrier = concurrentBarrier();
f.writes.length = 0;
f.hooks.beforeWrite = barrier.wait;
const results = await Promise.allSettled([
f.rooms.act(f.code, owner, beforeVersion, choice, clock),
f.restart().act(f.code, owner, beforeVersion, choice, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(barrier.count(), 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
results.filter((result) => result.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const selected = await f.restart().readRoom(f.code);
assert.equal(selected.version, beforeVersion + 1);
assert.equal(selected.leaderSkills!.offers[owner.playerId], undefined);
assert.deepEqual(
selected.leaderSkills!.assignments.filter(
(assignment) => assignment.owner === owner.playerId,
),
[
{
owner: owner.playerId,
leader: chosenLeader,
skill: chosenSkill,
},
],
);
assert.equal(
selected.leaderSkills!.deck.filter((skill) => skill === returnedSkill)
.length,
1,
);
assertSkillCustody(selected);
const ownerAfter = await f.restart().readSeatView(f.code, owner);
const nextAfter = await f.restart().readSeatView(f.code, f.auths[1]);
assert.equal(ownerAfter.leaderSkills?.offer, null);
assert.deepEqual(
nextAfter.leaderSkills?.offer,
initialOffers[f.auths[1].playerId],
);
assert.deepEqual(
ownerAfter.leaderSkills?.assignments,
nextAfter.leaderSkills?.assignments,
);
const snapshot = structuredClone(selected);
await assert.rejects(
f.restart().act(f.code, owner, beforeVersion, choice, clock),
);
assert.deepEqual(await f.restart().readRoom(f.code), snapshot);
} finally {
f.sqlite.close();
}
});
void test('real setup completion preserves prediction, starting hands, assignments and traitor choices across JSON restart', async () => {
const f = await fixture();
try {
let game = await act(f, 0, {
type: 'predict',
faction: 'atreides',
turn: 4,
});
assert.equal(game.setupStage, 'leaderSkills');
const prediction = structuredClone(game.players[0].prediction);
const startingHands = game.players.map((player) =>
player.hand.map((card) => card.id),
);
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
const offer = seat.leaderSkills!.offer!;
game = await act(f, index, {
type: 'leaderSkill',
event: offer.event,
skill: offer.cards[0],
leader: seat.leaderSkills!.eligibleLeaders[0].id,
});
assertSkillCustody(game);
}
assert.equal(game.setupStage, 'traitors');
const assignments = structuredClone(game.leaderSkills!.assignments);
for (const [index] of f.auths.entries()) {
game = await f.restart().readRoom(f.code);
const choice = game.players[index].traitorChoices[0];
assert.ok(choice);
game = await act(f, index, { type: 'traitor', leader: choice });
}
assert.equal(game.setupStage, 'forces');
const traitors = game.players.map((player) => [...player.traitors]);
game = await act(f, 1, {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.equal(game.status, 'playing');
assert.equal(game.setupStage, undefined);
assert.deepEqual(game.players[0].prediction, prediction);
assert.deepEqual(
game.players.map((player) => player.hand.map((card) => card.id)),
startingHands,
);
assert.deepEqual(
game.players.map((player) => player.traitors),
traitors,
);
assert.deepEqual(game.leaderSkills!.assignments, assignments);
assert.deepEqual(game.leaderSkills!.offers, {});
assertSkillCustody(game);
const persisted = structuredClone(game);
assert.deepEqual(await f.restart().readRoom(f.code), persisted);
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
assert.deepEqual(
seat.leaderSkills?.assignments,
assignments.map((assignment) => ({
...assignment,
controller: assignment.owner,
captured: false,
faceUp: true,
})),
);
assert.equal(seat.leaderSkills?.offer, null);
assert.deepEqual(
seat.players[index].hand?.map((card) => card.id),
startingHands[index],
);
for (const [otherIndex, player] of seat.players.entries())
if (otherIndex !== index) assert.equal(player.hand, undefined);
}
} finally {
f.sqlite.close();
}
});
void test('battle death and Ghola revival preserve one private replacement skill through restart and concurrent selection', async () => {
const f = await fixture('rihani-decipherer');
try {
let game = await act(f, 0, {
type: 'predict',
faction: 'atreides',
turn: 4,
});
assert.equal(
game.leaderSkills!.offers[f.auths[0].playerId].cards[0],
'rihani-decipherer',
);
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
const offer = seat.leaderSkills!.offer!;
game = await act(f, index, {
type: 'leaderSkill',
event: offer.event,
skill: offer.cards[0],
leader: seat.leaderSkills!.eligibleLeaders[0].id,
});
}
for (const [index] of f.auths.entries()) {
game = await f.restart().readRoom(f.code);
game = await act(f, index, {
type: 'traitor',
leader: game.players[index].traitorChoices[0],
});
}
game = await act(f, 1, {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.equal(game.status, 'playing');
const atreidesAuth = f.auths[2];
const bgAuth = f.auths[0];
const atreides = game.players.find(
(player) => player.id === atreidesAuth.playerId,
)!;
const bg = game.players.find((player) => player.id === bgAuth.playerId)!;
const oldAssignment = game.leaderSkills!.assignments.find(
(assignment) => assignment.owner === atreides.id,
)!;
for (const player of game.players) {
game.deck.push(...player.hand);
player.hand = [];
player.reserves += Object.values(player.forces).reduce(
(sum, amount) => sum + amount,
0,
);
player.forces = {};
}
const gholaIndex = game.deck.findIndex((card) => card.effect === 'ghola');
const poisonIndex = game.deck.findIndex((card) => card.kind === 'poison');
assert.ok(gholaIndex >= 0 && poisonIndex >= 0);
const [ghola] = game.deck.splice(gholaIndex, 1);
const adjustedPoisonIndex = game.deck.findIndex(
(card) => card.kind === 'poison',
);
const [poison] = game.deck.splice(adjustedPoisonIndex, 1);
atreides.hand.push(ghola);
bg.hand.push(poison);
atreides.reserves -= 5;
atreides.forces['arrakeen:10'] = 5;
bg.reserves -= 5;
bg.forces['arrakeen:10'] = 5;
Object.assign(game, {
phase: 6,
storm: 18,
order: [atreides.id, bg.id, f.auths[1].playerId],
active: atreides.id,
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(game), f.code);
game = await act(f, 2, {
type: 'chooseBattle',
territory: 'arrakeen',
target: bg.id,
});
if (game.decision?.kind === 'mentatQuestion') {
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.decision!.player,
);
assert.ok(owner >= 0);
game = await act(f, owner, {
type: 'decision',
event: game.decision.event,
decline: true,
});
}
while (game.decision?.kind === 'leaderSkillVisibility') {
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.decision!.player,
);
assert.ok(owner >= 0);
game = await act(f, owner, {
type: 'leaderSkillVisibility',
event: game.decision.event,
hide: true,
});
}
while (game.battle?.preparation) {
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.battle!.preparation!.owner,
);
assert.ok(owner >= 0);
game = await act(f, owner, { type: 'declineBattlePower' });
}
game = await act(f, 2, {
type: 'battlePlan',
dial: 0,
leader: oldAssignment.leader,
});
const bgLeader = game.leaderSkills!.assignments.find(
(assignment) => assignment.owner === bg.id,
)!.leader;
game = await act(f, 0, {
type: 'battlePlan',
dial: 1,
leader: bgLeader,
weapon: poison.id,
});
game = await act(f, 2, { type: 'traitorCall', call: false });
game = await act(f, 0, { type: 'traitorCall', call: false });
const dead = game.players
.find((player) => player.id === atreides.id)!
.leaders.find((leader) => leader.id === oldAssignment.leader)!;
assert.equal(dead.dead, true);
assert.equal(
game.leaderSkills!.assignments.some(
(assignment) => assignment.owner === atreides.id,
),
false,
);
assert.equal(
game.leaderSkills!.deck.filter((skill) => skill === oldAssignment.skill)
.length,
1,
);
assertSkillCustody(game);
// The seeded winner has a real saved Rihani choice before card cleanup.
assert.equal(game.decision?.kind, 'rihani');
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.decision!.player,
);
assert.ok(owner >= 0);
const seat = await f.restart().readSeatView(f.code, f.auths[owner]);
assert.equal(seat.automaticContinuationPending, false);
assert.equal(seat.decision?.kind, 'rihani');
game = await act(f, owner, {
type: 'decision',
event: seat.decision.event,
draw: false,
});
if (game.decision?.kind === 'battleCards')
game = await act(
f,
f.auths.findIndex((auth) => auth.playerId === game.decision!.player),
{ type: 'decision', discard: [] },
);
game = await act(f, 2, {
type: 'card',
card: ghola.id,
leader: oldAssignment.leader,
});
assert.equal(game.decision?.kind, 'leaderSkillRevival');
assert.equal(game.discard.filter((card) => card.id === ghola.id).length, 1);
const undrawn = await f.restart().readSeatView(f.code, atreidesAuth);
assert.deepEqual(undrawn.leaderSkills!.offer!.cards, []);
assert.equal(
(await f.restart().readSeatView(f.code, bgAuth)).leaderSkills!.offer,
null,
);
const event = undrawn.leaderSkills!.offer!.event;
game = await act(f, 2, {
type: 'leaderSkill',
event,
mode: 'draw',
});
const drawn = await f.restart().readSeatView(f.code, atreidesAuth);
const cards = [...drawn.leaderSkills!.offer!.cards];
assert.equal(cards.length, 2);
assert.equal(
(await f.restart().readSeatView(f.code, bgAuth)).leaderSkills!.offer,
null,
);
const choice: Action = {
type: 'leaderSkill',
event,
skill: cards[0],
leader: oldAssignment.leader,
};
const beforeVersion = game.version;
const barrier = concurrentBarrier();
f.writes.length = 0;
f.hooks.beforeWrite = barrier.wait;
const results = await Promise.allSettled([
f.rooms.act(f.code, atreidesAuth, beforeVersion, choice, clock),
f.restart().act(f.code, atreidesAuth, beforeVersion, choice, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(barrier.count(), 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
results.filter((result) => result.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const selected = await f.restart().readRoom(f.code);
assert.equal(selected.version, beforeVersion + 1);
assert.deepEqual(
selected.leaderSkills!.assignments.filter(
(assignment) => assignment.owner === atreides.id,
),
[
{
owner: atreides.id,
leader: oldAssignment.leader,
skill: cards[0],
},
],
);
assert.equal(
selected.leaderSkills!.deck.filter((skill) => skill === cards[0]).length,
0,
);
assert.equal(
selected.leaderSkills!.deck.filter((skill) => skill === cards[1]).length,
1,
);
assertSkillCustody(selected);
const snapshot = structuredClone(selected);
await assert.rejects(
f.restart().act(f.code, atreidesAuth, selected.version, choice, clock),
);
assert.deepEqual(await f.restart().readRoom(f.code), snapshot);
} finally {
f.sqlite.close();
}
});