437 lines
15 KiB
TypeScript
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);
|
|
}
|
|
});
|