372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { viewGame, type Action, type Game } from '../game/engine';
|
|
import { moritaniAssassinateSignature } from '../game/moritani-assassinate';
|
|
import type { RoomsClock } from '../db/rooms';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
import {
|
|
assassinationGame,
|
|
stageAssassinationBattle,
|
|
resolveAssassinationBattle,
|
|
assassinationActions,
|
|
assassinationPhysical,
|
|
} from './moritani-assassinate-fixture';
|
|
|
|
const clock: RoomsClock = { now: () => 42000, sleep: async () => {} };
|
|
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
|
const rows = (db: DatabaseSync) =>
|
|
db.prepare('SELECT state,version FROM rooms ORDER BY code').all();
|
|
|
|
async function fixture(t: test.TestContext, dead = false, truth = false) {
|
|
const store = unitStore();
|
|
t.after(() => store.sqlite.close());
|
|
const made = await store.rooms.createRoom(
|
|
'Assassinate recovery',
|
|
'moritani',
|
|
true,
|
|
['ecaz'],
|
|
);
|
|
const code = made.view.code,
|
|
tokens = [
|
|
made.token,
|
|
(await store.rooms.joinRoom(code, 'Guild', 'guild')).token!,
|
|
];
|
|
const auths = await Promise.all(
|
|
tokens.map((token) => store.rooms.authenticate(code, token)),
|
|
),
|
|
ids = auths.map((a) => a.playerId);
|
|
let g = stageAssassinationBattle(assassinationGame(ids), 'guild-1', dead);
|
|
let truthCard: string | undefined;
|
|
if (truth) {
|
|
const card = [...g.deck, ...g.players.flatMap((p) => p.hand)].find(
|
|
(c) => c.effect === 'truthtrance',
|
|
)!;
|
|
g.deck = g.deck.filter((c) => c.id !== card.id);
|
|
for (const p of g.players) p.hand = p.hand.filter((c) => c.id !== card.id);
|
|
g.players[0].hand.push(card);
|
|
truthCard = card.id;
|
|
}
|
|
g = resolveAssassinationBattle(g);
|
|
assert.equal(g.phase, 6);
|
|
assert.equal(g.decision?.kind, 'moritaniAssassinate');
|
|
g.code = code;
|
|
g.version = (await store.rooms.readRoom(code)).version;
|
|
store.sqlite
|
|
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
|
.run(JSON.stringify(g), g.version, code);
|
|
store.writes.length = 0;
|
|
const receipt = g.moritaniAssassinate!.opportunities.at(-1)!;
|
|
return {
|
|
...store,
|
|
code,
|
|
tokens,
|
|
auths,
|
|
ids,
|
|
g,
|
|
truthCard,
|
|
action: { type: 'decision', event: receipt.event, card: 'guild-1' },
|
|
};
|
|
}
|
|
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
|
|
|
async function restored(f: Fixture, expected: Game) {
|
|
const before = rows(f.sqlite);
|
|
for (const [index, token] of f.tokens.entries()) {
|
|
const rooms = f.restart(),
|
|
auth = await rooms.authenticate(f.code, token),
|
|
view = await rooms.readSeatView(f.code, auth);
|
|
assert.deepEqual(view, viewGame(expected, f.ids[index]));
|
|
assert.equal('moritaniAssassinatePreview' in view, false);
|
|
assert.equal('moritaniAssassinateResume' in view, false);
|
|
for (const rival of view.players.filter((p) => p.id !== f.ids[index])) {
|
|
assert.equal('traitors' in rival, false);
|
|
assert.equal('hand' in rival, false);
|
|
assert.equal('spice' in rival, false);
|
|
}
|
|
if (index === 1)
|
|
assert.deepEqual(view.moritaniAssassinate?.pending?.cards ?? [], []);
|
|
for (const record of view.moritaniAssassinate?.history ?? []) {
|
|
assert.equal('signature' in record, false);
|
|
assert.equal('replacement' in record, false);
|
|
}
|
|
}
|
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
|
assert.deepEqual(rows(f.sqlite), before);
|
|
}
|
|
async function act(
|
|
f: Fixture,
|
|
index: number,
|
|
action: Action,
|
|
version?: number,
|
|
) {
|
|
const rooms = f.restart(),
|
|
auth = await rooms.authenticate(f.code, f.tokens[index]),
|
|
g = await rooms.readRoom(f.code);
|
|
await rooms.act(f.code, auth, version ?? g.version, action, clock);
|
|
return f.restart().readRoom(f.code);
|
|
}
|
|
async function actualMentat(f: Fixture, state: Game) {
|
|
let g = state;
|
|
for (let n = 0; g.phase !== 8 && n < 30; n++) {
|
|
const candidates = g.players.flatMap((p, index) =>
|
|
assassinationActions(g, p.id).map((action) => ({ index, action })),
|
|
);
|
|
const next =
|
|
candidates.find((c) => c.action.type === 'ready') ?? candidates[0];
|
|
assert.ok(
|
|
next,
|
|
`Actual saved continuation must progress from phase ${g.phase}`,
|
|
);
|
|
g = await act(f, next.index, next.action);
|
|
}
|
|
assert.equal(g.phase, 8);
|
|
return g;
|
|
}
|
|
|
|
void test('saved live and already-dead Assassinate reveals settle once, keep the physical traitor until actual Mentat and draw one private replacement', async (t) => {
|
|
for (const dead of [false, true]) {
|
|
const f = await fixture(t, dead),
|
|
initial = clone(f.g),
|
|
physical = assassinationPhysical(initial);
|
|
const target = initial.players[1].leaders.find((l) => l.id === 'guild-1')!,
|
|
bounty = dead ? 0 : target.strength;
|
|
const seats = f.sqlite
|
|
.prepare('SELECT * FROM seats ORDER BY player_id')
|
|
.all();
|
|
await restored(f, initial);
|
|
const before = rows(f.sqlite);
|
|
await assert.rejects(act(f, 1, f.action));
|
|
await assert.rejects(act(f, 0, { ...f.action, event: 'old' }));
|
|
await assert.rejects(act(f, 0, { ...f.action, card: 'guild-0' }));
|
|
await assert.rejects(
|
|
act(f, 0, f.action, initial.version - 1),
|
|
/table changed/,
|
|
);
|
|
assert.deepEqual(rows(f.sqlite), before);
|
|
assert.equal(f.writes.length, 0);
|
|
const revealed = await act(f, 0, f.action, initial.version),
|
|
receipt = revealed.moritaniAssassinate!.opportunities.at(-1)!;
|
|
assert.equal(revealed.version, initial.version + 1);
|
|
assert.equal(revealed.phase, 7);
|
|
assert.equal(revealed.decision, null);
|
|
assert.equal(revealed.moritaniAssassinateResume, undefined);
|
|
assert.equal(receipt.stage, 'revealed');
|
|
assert.equal(receipt.bounty, bounty);
|
|
assert.equal(receipt.replacement, null);
|
|
assert.equal(revealed.players[0].spice, initial.players[0].spice + bounty);
|
|
const afterTarget = revealed.players[1].leaders.find(
|
|
(l) => l.id === target.id,
|
|
)!;
|
|
assert.equal(afterTarget.dead, true);
|
|
assert.equal(afterTarget.deaths, target.deaths + Number(!dead));
|
|
assert.deepEqual(
|
|
revealed.players[1].leaders.filter((l) => l.id !== target.id),
|
|
initial.players[1].leaders.filter((l) => l.id !== target.id),
|
|
);
|
|
assert.deepEqual(revealed.players[0].traitors, initial.players[0].traitors);
|
|
assert.deepEqual(revealed.traitorReserve, initial.traitorReserve);
|
|
assert.deepEqual(assassinationPhysical(revealed), physical);
|
|
await restored(f, revealed);
|
|
const settled = rows(f.sqlite),
|
|
writes = f.writes.length;
|
|
await assert.rejects(act(f, 0, f.action, initial.version), /table changed/);
|
|
await assert.rejects(act(f, 0, f.action, revealed.version));
|
|
assert.deepEqual(rows(f.sqlite), settled);
|
|
assert.equal(f.writes.length, writes);
|
|
const replacement = revealed.traitorReserve![0],
|
|
mentat = await actualMentat(f, revealed),
|
|
done = mentat.moritaniAssassinate!.opportunities.at(-1)!;
|
|
assert.equal(done.stage, 'replaced');
|
|
assert.equal(done.replacement, replacement);
|
|
assert.deepEqual(mentat.traitorReserve, revealed.traitorReserve!.slice(1));
|
|
assert.deepEqual(mentat.players[0].traitors, [
|
|
...revealed.players[0].traitors.filter((c) => c !== 'guild-1'),
|
|
replacement,
|
|
]);
|
|
assert.equal(
|
|
mentat.players.flatMap((p) => p.traitors).includes('guild-1'),
|
|
false,
|
|
);
|
|
assert.equal(mentat.traitorReserve!.includes('guild-1'), false);
|
|
assert.equal(
|
|
mentat.moritaniAssassinate!.opportunities.filter(
|
|
(r) => r.stage === 'replaced' && r.card === 'guild-1',
|
|
).length,
|
|
1,
|
|
);
|
|
assert.deepEqual(assassinationPhysical(mentat), physical);
|
|
await restored(f, mentat);
|
|
const saved = rows(f.sqlite);
|
|
await assert.rejects(act(f, 0, f.action));
|
|
assert.deepEqual(rows(f.sqlite), saved);
|
|
assert.deepEqual(
|
|
f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
|
seats,
|
|
);
|
|
assert.ok(f.writes.every((w) => w.changes === 1));
|
|
}
|
|
});
|
|
|
|
void test('concurrent identical Assassinate reveals commit only one death, bounty and delayed replacement', async (t) => {
|
|
const f = await fixture(t),
|
|
target = f.g.players[1].leaders.find((l) => l.id === 'guild-1')!;
|
|
let arrivals = 0,
|
|
release!: () => void;
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
f.hooks.beforeWrite = async () => {
|
|
if (++arrivals === 2) release();
|
|
await gate;
|
|
};
|
|
const timer = setTimeout(release, 2000);
|
|
let results: PromiseSettledResult<unknown>[];
|
|
try {
|
|
results = await Promise.allSettled(
|
|
[0, 1].map(() => act(f, 0, f.action, f.g.version)),
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
delete f.hooks.beforeWrite;
|
|
}
|
|
assert.equal(arrivals, 2);
|
|
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
|
assert.deepEqual(
|
|
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
|
[0, 1],
|
|
);
|
|
const g = await f.restart().readRoom(f.code);
|
|
assert.equal(g.version, f.g.version + 1);
|
|
assert.equal(g.players[0].spice, f.g.players[0].spice + target.strength);
|
|
assert.equal(
|
|
g.players[1].leaders.find((l) => l.id === target.id)!.deaths,
|
|
target.deaths + 1,
|
|
);
|
|
assert.equal(g.moritaniAssassinate!.opportunities.length, 1);
|
|
const mentat = await actualMentat(f, g);
|
|
assert.equal(mentat.traitorReserve!.length, f.g.traitorReserve!.length - 1);
|
|
assert.equal(mentat.moritaniAssassinate!.opportunities[0].stage, 'replaced');
|
|
assert.deepEqual(assassinationPhysical(mentat), assassinationPhysical(f.g));
|
|
await restored(f, mentat);
|
|
});
|
|
|
|
void test('a real Truthtrance interruption restores the same private Assassinate choice and original battle cleanup', async (t) => {
|
|
const f = await fixture(t, false, true),
|
|
resume = clone(f.g.moritaniAssassinateResume),
|
|
physical = assassinationPhysical(f.g);
|
|
let g = await act(f, 0, { type: 'card', card: f.truthCard });
|
|
assert.equal(g.truthtrance?.stage, 'priority');
|
|
await restored(f, g);
|
|
while (g.truthtrance?.stage === 'priority') {
|
|
const p = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!;
|
|
g = await act(f, f.ids.indexOf(p.id), { type: 'truthPass' });
|
|
}
|
|
g = await act(f, 0, {
|
|
type: 'truthAsk',
|
|
question: {
|
|
kind: 'fact',
|
|
target: f.ids[1],
|
|
fact: { kind: 'spice', compare: 'gte', value: 0 },
|
|
},
|
|
});
|
|
await restored(f, g);
|
|
assert.deepEqual(g.moritaniAssassinateResume, resume);
|
|
const pending = rows(f.sqlite);
|
|
await assert.rejects(act(f, 0, f.action));
|
|
assert.deepEqual(rows(f.sqlite), pending);
|
|
g = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
|
|
assert.equal(g.truthtrance, null);
|
|
assert.equal(g.decision?.kind, 'moritaniAssassinate');
|
|
assert.deepEqual(g.moritaniAssassinateResume, resume);
|
|
assert.equal(g.discard.filter((c) => c.id === f.truthCard).length, 1);
|
|
assert.deepEqual(assassinationPhysical(g), physical);
|
|
await restored(f, g);
|
|
g = await act(f, 0, {
|
|
type: 'decision',
|
|
event: f.action.event,
|
|
decline: true,
|
|
});
|
|
assert.equal(g.phase, 7);
|
|
assert.equal(g.moritaniAssassinate!.opportunities[0].stage, 'declined');
|
|
assert.deepEqual(g.players[0].traitors, f.g.players[0].traitors);
|
|
assert.deepEqual(g.traitorReserve, f.g.traitorReserve);
|
|
const mentat = await actualMentat(f, g);
|
|
assert.deepEqual(mentat.traitorReserve, f.g.traitorReserve);
|
|
assert.deepEqual(assassinationPhysical(mentat), physical);
|
|
await restored(f, mentat);
|
|
});
|
|
|
|
void test('damaged saved Assassinate cleanup, receipt, obligation or decision rejects reads and actions without SQLite repair', async (t) => {
|
|
const f = await fixture(t);
|
|
for (const damage of [
|
|
(g: Game) => {
|
|
delete g.moritaniAssassinateResume;
|
|
},
|
|
(g: Game) => {
|
|
g.moritaniAssassinateResume!.signature = 'changed';
|
|
},
|
|
(g: Game) => {
|
|
const r = g.moritaniAssassinateResume!;
|
|
r.continuation.winner = f.ids[0];
|
|
r.signature = JSON.stringify(r.continuation);
|
|
},
|
|
(g: Game) => {
|
|
const r = g.moritaniAssassinateResume!;
|
|
r.continuation.territory = 'carthag';
|
|
r.signature = JSON.stringify(r.continuation);
|
|
},
|
|
(g: Game) => {
|
|
const r = g.moritaniAssassinateResume!;
|
|
r.continuation.combatants = [f.ids[0]];
|
|
r.signature = JSON.stringify(r.continuation);
|
|
},
|
|
(g: Game) => {
|
|
const r = g.moritaniAssassinateResume!;
|
|
r.continuation.cards = [g.deck[0].id];
|
|
r.signature = JSON.stringify(r.continuation);
|
|
},
|
|
(g: Game) => {
|
|
const r = g.moritaniAssassinateResume!;
|
|
r.continuation.casualties!.options[0].normal = 1;
|
|
r.signature = JSON.stringify(r.continuation);
|
|
},
|
|
(g: Game) => {
|
|
g.moritaniAssassinate!.opportunities[0].card = 'guild-1';
|
|
},
|
|
(g: Game) => {
|
|
const r = g.moritaniAssassinate!.opportunities[0];
|
|
r.opposingLeader = 'guild-2';
|
|
r.signature = moritaniAssassinateSignature(r);
|
|
},
|
|
(g: Game) => {
|
|
delete g.lastBattleContext!.moritaniAssassinate;
|
|
},
|
|
(g: Game) => {
|
|
delete g.lastBattleContext!.moritaniAssassinate!.continuation;
|
|
},
|
|
(g: Game) => {
|
|
delete g.moritaniAssassinateCallEvents;
|
|
},
|
|
(g: Game) => {
|
|
g.decision = null;
|
|
},
|
|
]) {
|
|
const g = clone(f.g);
|
|
damage(g);
|
|
f.sqlite
|
|
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
|
.run(JSON.stringify(g), f.code);
|
|
const before = rows(f.sqlite),
|
|
writes = f.writes.length;
|
|
for (const token of f.tokens) {
|
|
const rooms = f.restart(),
|
|
auth = await rooms.authenticate(f.code, token);
|
|
await assert.rejects(rooms.readSeatView(f.code, auth), /assassinat/i);
|
|
}
|
|
await assert.rejects(act(f, 0, f.action), /assassinat/i);
|
|
assert.deepEqual(rows(f.sqlite), before);
|
|
assert.equal(f.writes.length, writes);
|
|
}
|
|
});
|