Files
Dune/tests/recruits-recovery.test.ts
T

437 lines
15 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import { viewGame, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { recruitsPlayAction } from '../game/recruits';
import { traitorDeck } from '../game/traitors';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { recruitsGame } from './fixture-recruits';
const clock: RoomsClock = { now: () => 73000, sleep: async () => {} };
const play: Action = { type: 'card', card: 'ecaz-recruits' };
const rows = (sqlite: DatabaseSync) => ({
rooms: sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(),
seats: sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
});
function custody(g: Game) {
const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
assert.deepEqual(
cards.map((card) => card.id).sort(),
[...treacheryDeck(g.expansions), ...ecazTreacheryCards()]
.map((card) => card.id)
.sort(),
);
const traitors = [
...(g.traitorReserve ?? []),
...g.players.flatMap((p) => [
...p.traitors,
...(p.faceDancers ?? []).map((card) => card.leader),
]),
];
assert.deepEqual(traitors.sort(), traitorDeck(g.players, true).sort());
for (const p of g.players) {
const values = [p.reserves, p.tanks, ...Object.values(p.forces)];
assert.ok(values.every((n) => Number.isSafeInteger(n) && n >= 0));
assert.equal(
values.reduce((a, b) => a + b, 0),
20,
);
if (p.elites) {
const elite = [
p.elites.reserves,
p.elites.tanks,
...Object.values(p.elites.forces),
];
assert.ok(elite.every((n) => Number.isSafeInteger(n) && n >= 0));
assert.equal(
elite.reduce((a, b) => a + b, 0),
3,
);
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
for (const [location, amount] of Object.entries(p.elites.forces))
assert.ok(amount <= (p.forces[location] ?? 0));
}
}
}
async function fixture(t: test.TestContext, advanced = true) {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom(
'Recruits recovery',
'atreides',
advanced,
['ix', 'choam'],
);
const code = made.view.code,
tokens = [made.token];
for (const faction of ['fremen', 'tleilaxu', 'choam'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const ids = [
auths[0].playerId,
auths[1].playerId,
auths[2].playerId,
auths[3].playerId,
] as const;
// Genuine identities/setup and dealt cards; the shared fixture explicitly stages Revival and normal losses.
const g = recruitsGame(advanced, 3, ids);
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
custody(g);
const owner = g.players.find((p) =>
p.hand.some((card) => card.id === 'ecaz-recruits'),
)!.id;
assert.equal(owner, ids[3]);
store.sqlite
.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
return { ...store, code, tokens, auths, ids, owner };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
async function act(f: Fixture, player: string, action: Action) {
const rooms = f.restart(),
index = f.ids.indexOf(player);
assert.ok(index >= 0);
const auth = await rooms.authenticate(f.code, f.tokens[index]);
const before = await rooms.readRoom(f.code);
await rooms.act(f.code, auth, before.version, action, clock);
const after = await rooms.readRoom(f.code);
custody(after);
return after;
}
async function restored(f: Fixture) {
const g = await f.restart().readRoom(f.code),
before = rows(f.sqlite);
const publicRates = viewGame(g, f.owner).recruitsPreview!.rates;
for (const [index, token] of f.tokens.entries()) {
const rooms = f.restart(),
auth = await rooms.authenticate(f.code, token);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(g, f.ids[index]));
assert.deepEqual(
view,
viewGame(JSON.parse(JSON.stringify(g)) as Game, f.ids[index]),
);
assert.deepEqual(view.recruitsPreview!.rates, publicRates);
if (auth.playerId !== f.owner)
assert.equal(view.recruitsPreview!.play, null);
for (const rival of view.players.filter((p) => p.id !== auth.playerId))
for (const field of [
'hand',
'traitors',
'spice',
'prediction',
'faceDancers',
])
assert.equal(field in rival, false);
}
assert.deepEqual(rows(f.sqlite), before);
custody(g);
return g;
}
async function noWrite(
f: Fixture,
player: string,
action: Action,
version?: number,
reason?: RegExp,
) {
const rooms = f.restart(),
auth = await rooms.authenticate(f.code, f.tokens[f.ids.indexOf(player)]);
const current = await rooms.readRoom(f.code),
before = rows(f.sqlite),
writes = f.writes.length;
const attempt = rooms.act(
f.code,
auth,
version ?? current.version,
action,
clock,
);
if (reason) await assert.rejects(attempt, reason);
else await assert.rejects(attempt);
assert.deepEqual(rows(f.sqlite), before);
assert.equal(f.writes.length, writes);
}
/** Allow only the existing revival/CHOAM/Tleilaxu continuation through authenticated room actions. */
async function settleRevival(f: Fixture) {
for (let step = 0; step < 30; step++) {
const g = await f.restart().readRoom(f.code);
if (!g.pendingRevival && !g.decision && !g.response) return g;
if (g.decision) {
assert.ok(['choamFreeRevival', 'revivalStop'].includes(g.decision.kind));
await act(f, g.decision.player, { type: 'decision', decline: true });
} else if (g.response) {
const next = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(next);
await act(f, next.id, { type: 'passResponse' });
} else assert.fail('A pending revival needs an explicit continuation.');
}
assert.fail('The saved revival failed to settle.');
}
void test('Recruits restores privately and counts earlier free revivals once across later free and paid returns', async (t) => {
for (const advanced of [false, true]) {
const f = await fixture(t, advanced),
original = await restored(f);
const seats = rows(f.sqlite).seats,
initialAtreides = original.players[0];
assert.deepEqual(
recruitsPlayAction(viewGame(original, f.owner).recruitsPreview),
play,
);
await noWrite(f, f.ids[0], play, undefined, /not in your hand/);
await noWrite(
f,
f.owner,
{ ...play, blocked: null },
undefined,
/additional mode or selection/,
);
await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
const priorFree = await settleRevival(f);
assert.equal(priorFree.players[0].freeForcesRevived, 2);
assert.equal(priorFree.players[0].revived, 2);
assert.equal(priorFree.players[0].spice, initialAtreides.spice);
const played = await act(f, f.owner, play);
assert.deepEqual(played.recruits, {
turn: 2,
player: f.owner,
card: 'ecaz-recruits',
});
assert.equal(
played.discard.filter((card) => card.id === 'ecaz-recruits').length,
1,
);
assert.equal(
played.players
.flatMap((p) => p.hand)
.some((card) => card.id === 'ecaz-recruits'),
false,
);
for (const p of played.players) {
const before = priorFree.players.find(
(candidate) => candidate.id === p.id,
)!;
assert.deepEqual(
[p.spice, p.reserves, p.tanks, p.revived, p.freeForcesRevived],
[
before.spice,
before.reserves,
before.tanks,
before.revived,
before.freeForcesRevived,
],
);
}
const active = await restored(f);
assert.deepEqual(
viewGame(active, f.owner).recruitsPreview!.rates,
active.order.map((player) => {
const faction = active.players.find((p) => p.id === player)!.faction;
return {
player,
freeRate: faction === 'fremen' ? 6 : faction === 'choam' ? 0 : 4,
limit: faction === 'tleilaxu' || faction === 'choam' ? 20 : 7,
};
}),
);
await noWrite(f, f.owner, play, priorFree.version);
await noWrite(f, f.owner, play, active.version, /not in your hand/);
await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
const four = await settleRevival(f);
assert.equal(four.players[0].freeForcesRevived, 4);
assert.equal(four.players[0].revived, 4);
assert.equal(four.players[0].spice, initialAtreides.spice);
await restored(f);
await act(f, f.ids[0], { type: 'revive', amount: 3, elite: 0 });
const seven = await settleRevival(f);
assert.equal(seven.players[0].freeForcesRevived, 4);
assert.equal(seven.players[0].revived, 7);
assert.equal(seven.players[0].spice, initialAtreides.spice - 6);
assert.equal(seven.players[0].tanks, initialAtreides.tanks - 7);
assert.equal(seven.players[0].reserves, initialAtreides.reserves + 7);
assert.equal(
seven.discard.filter((card) => card.id === 'ecaz-recruits').length,
1,
);
await noWrite(f, f.ids[0], { type: 'revive', amount: 1, elite: 0 });
await restored(f);
assert.deepEqual(rows(f.sqlite).seats, seats);
}
});
void test('pending revivals stay immutable after restart while settled paid returns allow provisional Recruits without refunds', async (t) => {
const f = await fixture(t);
const pending = await act(f, f.ids[0], {
type: 'revive',
amount: 3,
elite: 0,
});
assert.equal(pending.decision?.kind, 'choamFreeRevival');
assert.equal(pending.pendingRevival?.cost, 2);
await restored(f);
assert.match(
viewGame(pending, f.owner).recruitsPreview!.play!.blocked!,
/transaction or priority/,
);
await noWrite(f, f.owner, play, undefined, /transaction or priority/);
assert.deepEqual(
(await f.restart().readRoom(f.code)).pendingRevival,
pending.pendingRevival,
);
const paid = await settleRevival(f);
assert.equal(paid.players[0].revived, 3);
assert.equal(paid.players[0].freeForcesRevived, 2);
assert.equal(paid.players[0].spice, 18);
const action = recruitsPlayAction(viewGame(paid, f.owner).recruitsPreview);
assert.deepEqual(action, play);
assert.ok(action);
const played = await act(f, f.owner, action);
assert.deepEqual(played.recruits, {
turn: paid.turn,
player: f.owner,
card: 'ecaz-recruits',
});
assert.deepEqual(
played.players.map((player) => player.spice),
paid.players.map((player) => player.spice),
);
assert.equal(played.players[0].revived, 3);
assert.equal(played.players[0].freeForcesRevived, 2);
assert.ok(!played.players[3].hand.some((card) => card.id === 'ecaz-recruits'));
assert.equal(played.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
const final = await restored(f);
assert.equal(viewGame(final, f.ids[0]).revival.freeRemaining, 2);
assert.equal(viewGame(final, f.ids[0]).revival.forcesRemaining, 4);
await noWrite(f, f.owner, play);
await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
const free = await settleRevival(f);
assert.equal(free.players[0].revived, 5);
assert.equal(free.players[0].freeForcesRevived, 4);
assert.equal(free.players[0].spice, paid.players[0].spice);
assert.equal(viewGame(free, f.ids[0]).revival.freeRemaining, 0);
assert.equal(viewGame(free, f.ids[0]).revival.forcesRemaining, 2);
await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
const capped = await settleRevival(f);
assert.equal(capped.players[0].revived, 7);
assert.equal(capped.players[0].freeForcesRevived, 4);
assert.equal(capped.players[0].spice, paid.players[0].spice - 4);
assert.equal(viewGame(capped, f.ids[0]).revival.forcesRemaining, 0);
assert.equal(capped.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
await noWrite(f, f.ids[0], { type: 'revive', amount: 1, elite: 0 });
await restored(f);
});
void test('competing authenticated Recruits plays commit one physical discard and one effect', async (t) => {
const f = await fixture(t),
before = await f.rooms.readRoom(f.code),
seatRows = rows(f.sqlite).seats;
let entered = 0,
release!: () => void;
const both = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++entered === 2) release();
await both;
};
const attempts = await Promise.allSettled(
[0, 1].map(() =>
f.rooms.act(f.code, f.auths[3], before.version, play, clock),
),
);
f.hooks.beforeWrite = undefined;
assert.equal(
attempts.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const after = await restored(f);
assert.equal(after.version, before.version + 1);
assert.equal(
after.discard.filter((card) => card.id === 'ecaz-recruits').length,
1,
);
assert.equal(
after.log.filter((entry) => entry.text.includes('played Recruits.')).length,
1,
);
for (const p of after.players) assert.equal(p.revived, 0);
await noWrite(f, f.owner, play, before.version);
await noWrite(f, f.owner, play, after.version);
assert.deepEqual(rows(f.sqlite).seats, seatRows);
});
void test('malformed active Recruits saves fail private reads and actions without SQL writes', async (t) => {
const f = await fixture(t),
valid = await act(f, f.owner, play);
const mutations: ((g: Game) => void)[] = [
(g) => {
g.recruits!.turn++;
},
(g) => {
g.players[0].freeForcesRevived = 1;
},
(g) => {
g.players[0].hand.push(
g.discard.find((card) => card.id === 'ecaz-recruits')!,
);
},
];
for (const mutate of mutations) {
const corrupt = JSON.parse(JSON.stringify(valid)) as Game;
mutate(corrupt);
f.sqlite
.prepare('UPDATE rooms SET state=? WHERE code=?')
.run(JSON.stringify(corrupt), f.code);
const before = rows(f.sqlite),
writes = f.writes.length;
for (const [index, token] of f.tokens.entries()) {
const rooms = f.restart(),
auth = await rooms.authenticate(f.code, token);
await assert.rejects(
rooms.readSeatView(f.code, auth),
/Recruits|Ecaz Treachery/,
);
assert.throws(
() => viewGame(corrupt, f.ids[index]),
/Recruits|Ecaz Treachery/,
);
}
await noWrite(
f,
f.owner,
{ type: 'ready' },
valid.version,
/Recruits|Ecaz Treachery/,
);
await noWrite(
f,
f.owner,
{ type: 'setAutopilot', difficulty: 'Easy' },
valid.version,
/Recruits|Ecaz Treachery/,
);
assert.deepEqual(rows(f.sqlite), before);
assert.equal(f.writes.length, writes);
}
});