407 lines
21 KiB
TypeScript
407 lines
21 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import type { DatabaseSync } from 'node:sqlite';
|
|
import type * as Rooms from '../db/rooms';
|
|
import { viewGame, type Action, type Game, type GameView } from '../game/engine';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
|
|
|
|
const clock = { now: () => 10000, sleep: async () => {} };
|
|
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
|
const credentials = (sqlite: DatabaseSync) => ({
|
|
seats: sqlite.prepare('SELECT * FROM seats ORDER BY token_hash').all(),
|
|
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY operation_hash').all(),
|
|
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code, player_id').all(),
|
|
recoveries: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code, player_id, operation_hash').all(),
|
|
});
|
|
|
|
function inventory(g: Game) {
|
|
return [...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...g.players.flatMap(p => p.hand)]
|
|
.map(card => card.id).sort();
|
|
}
|
|
|
|
async function persisted(prepare?: (g: Game) => void) {
|
|
const store = unitStore();
|
|
try {
|
|
const created = await store.rooms.createRoom('Kull SQLite QA', 'choam', true, ['choam', 'ix']);
|
|
const code = created.view.code;
|
|
const joined = [
|
|
await store.rooms.joinRoom(code, 'Emperor', 'emperor'),
|
|
await store.rooms.joinRoom(code, 'Bene Gesserit', 'beneGesserit'),
|
|
await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen'),
|
|
];
|
|
const tokens = [created.token, ...joined.map(seat => seat.token!)];
|
|
const auths = await Promise.all(tokens.map(token => store.restart().authenticate(code, token)));
|
|
const current = await store.rooms.readRoom(code);
|
|
const initial = choamKullGame({ seatIds: auths.map(auth => auth.playerId) });
|
|
initial.code = code;
|
|
initial.host = auths[0].playerId;
|
|
initial.version = current.version;
|
|
prepare?.(initial);
|
|
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
|
.run(JSON.stringify(initial), initial.version, code, current.version).changes, 1);
|
|
store.writes.length = 0;
|
|
return { ...store, code, tokens, auths, initial,
|
|
originalCredentials: credentials(store.sqlite), originalCards: inventory(initial) };
|
|
} catch (error) {
|
|
store.sqlite.close();
|
|
throw error;
|
|
}
|
|
}
|
|
type Fixture = {
|
|
rooms: typeof Rooms;
|
|
restart: () => typeof Rooms;
|
|
sqlite: DatabaseSync;
|
|
hooks: { beforeWrite?: () => Promise<void>; beforeStatement?: (sql: string) => Promise<void> };
|
|
writes: { expected: number; changes: number }[];
|
|
code: string;
|
|
tokens: string[];
|
|
auths: Rooms.SeatAuth[];
|
|
initial: Game;
|
|
originalCredentials: { seats: unknown[]; entries: unknown[]; keys: unknown[]; recoveries: unknown[] };
|
|
originalCards: string[];
|
|
};
|
|
|
|
async function act(f: Fixture, g: Game, actor: string, action: Action) {
|
|
const rooms = f.restart();
|
|
const auth = f.auths.find(auth => auth.playerId === actor)!;
|
|
await rooms.act(f.code, auth, g.version, action, clock);
|
|
return rooms.readRoom(f.code);
|
|
}
|
|
|
|
async function restored(f: Fixture, g: Game) {
|
|
const rooms = f.restart();
|
|
const views: GameView[] = [];
|
|
for (let index = 0; index < f.tokens.length; index++) {
|
|
const auth = await rooms.authenticate(f.code, f.tokens[index]);
|
|
const view = await rooms.readSeatView(f.code, auth);
|
|
assert.deepEqual(view, viewGame(g, auth.playerId));
|
|
if (g.pendingKull) {
|
|
assert.equal('pendingKull' in view, false, 'saved intent and suspended controls are not a projection');
|
|
assert.equal(JSON.stringify(view).includes(JSON.stringify(g.pendingKull.signature)), false);
|
|
}
|
|
assert.deepEqual(view.players.find(p => p.id === auth.playerId)!.hand, player(g, auth.playerId).hand);
|
|
for (const other of view.players.filter(p => p.id !== auth.playerId)) {
|
|
assert.equal(other.hand, undefined);
|
|
assert.equal(other.spice, undefined);
|
|
}
|
|
views.push(view);
|
|
}
|
|
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
|
|
assert.deepEqual(inventory(g), f.originalCards);
|
|
assert.equal(new Set(inventory(g)).size, f.originalCards.length);
|
|
return views;
|
|
}
|
|
|
|
async function rejected(f: Fixture, g: Game, actor: string, action: Action, version = g.version) {
|
|
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
|
|
const attemptedStatements: string[] = [];
|
|
f.hooks.beforeStatement = async sql => { attemptedStatements.push(sql); };
|
|
try {
|
|
await assert.rejects(f.restart().act(f.code, f.auths.find(auth => auth.playerId === actor)!,
|
|
version, action, clock));
|
|
} finally { delete f.hooks.beforeStatement; }
|
|
assert.deepEqual(attemptedStatements, [], 'rejected game actions must not attempt SQL writes');
|
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
|
|
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
|
|
}
|
|
|
|
async function stationary(f: Fixture, g: Game) {
|
|
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
|
|
const writes = f.writes.length;
|
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
|
|
assert.equal(f.writes.length, writes);
|
|
await restored(f, g);
|
|
}
|
|
|
|
async function passes(f: Fixture, initial: Game) {
|
|
let g = initial;
|
|
for (let step = 0; g.response && step < 16; step++) {
|
|
const responder = g.players.find(p => !g.response!.passed.includes(p.id));
|
|
assert.ok(responder, 'an unresolved response must retain a real seat');
|
|
g = await act(f, g, responder.id, { type: 'passResponse' });
|
|
await restored(f, g);
|
|
}
|
|
assert.equal(g.response, null);
|
|
return g;
|
|
}
|
|
|
|
async function compete(f: Fixture, g: Game, actor: string, action: Action) {
|
|
let arrivals = 0;
|
|
let release!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const priorWrites = f.writes.length;
|
|
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
|
const auth = f.auths.find(auth => auth.playerId === actor)!;
|
|
try {
|
|
const outcomes = await Promise.allSettled([
|
|
f.rooms.act(f.code, auth, g.version, action, clock),
|
|
f.restart().act(f.code, auth, g.version, action, clock),
|
|
]);
|
|
assert.equal(arrivals, 2);
|
|
assert.equal(outcomes.filter(outcome => outcome.status === 'fulfilled').length, 1);
|
|
assert.equal(outcomes.filter(outcome => outcome.status === 'rejected').length, 1);
|
|
assert.deepEqual(f.writes.slice(priorWrites).map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
|
} finally { delete f.hooks.beforeWrite; }
|
|
const done = await f.restart().readRoom(f.code);
|
|
assert.equal(done.version, g.version + 1);
|
|
await restored(f, done);
|
|
return done;
|
|
}
|
|
|
|
async function offer(f: Fixture, initial: Game, actor: string, action: Action, expectedCards = ['ix-kull-wahad']) {
|
|
const g = await act(f, initial, actor, action);
|
|
const views = await restored(f, g);
|
|
const reaction = views[0].kullReaction;
|
|
assert.ok(reaction);
|
|
assert.equal(reaction.player, f.auths[0].playerId);
|
|
assert.equal(reaction.target, actor);
|
|
assert.equal(reaction.canDecline, true);
|
|
assert.deepEqual(reaction.cards.map(card => card.id), expectedCards);
|
|
const publicFields = { event: reaction.event, player: reaction.player, target: reaction.target,
|
|
intent: reaction.intent, cards: [], canDecline: false, blocked: null };
|
|
for (const view of views.slice(1)) assert.deepEqual(view.kullReaction, publicFields);
|
|
assert.deepEqual(g.players, initial.players, 'the public offer must precede every card, revival, income and once-use cost');
|
|
assert.deepEqual(g.discard, initial.discard);
|
|
await stationary(f, g);
|
|
return { g, event: reaction.event };
|
|
}
|
|
|
|
void test('persisted Kull offer and racing decline revive Emperor forces exactly once after restart', async () => {
|
|
const f = await persisted(g => {
|
|
g.phase = 4;
|
|
g.active = null;
|
|
player(g, g.players[1].id).tanks = 4;
|
|
player(g, g.players[1].id).reserves -= 4;
|
|
});
|
|
try {
|
|
const emperor = f.auths[1].playerId;
|
|
const card = player(f.initial, emperor).hand.find(card => card.effect === 'karama')!;
|
|
const original = { type: 'card', mode: 'special', card: card.id, amount: 2 };
|
|
const { g, event } = await offer(f, f.initial, emperor, original);
|
|
const decline = { type: 'kullDecision', event, decline: true };
|
|
await rejected(f, g, emperor, decline);
|
|
await rejected(f, g, f.auths[0].playerId, { ...decline, event: `${event}-obsolete` });
|
|
await rejected(f, g, f.auths[0].playerId, { type: 'kullDecision', event, card: card.id });
|
|
await rejected(f, g, f.auths[0].playerId, { type: 'kullDecision', event, decline: 'yes' });
|
|
await rejected(f, g, f.auths[0].playerId, decline, g.version - 1);
|
|
const done = await compete(f, g, f.auths[0].playerId, decline);
|
|
assert.equal(player(done, emperor).tanks, 2);
|
|
assert.equal(player(done, emperor).reserves, player(f.initial, emperor).reserves + 2);
|
|
assert.equal(player(done, emperor).specialKaramaUsed, true);
|
|
assert.equal(done.discard.filter(held => held.id === card.id).length, 1);
|
|
assert.equal(player(done, f.auths[0].playerId).hand.filter(held => held.id === 'ix-kull-wahad').length, 1);
|
|
assert.equal(viewGame(done, emperor).kullReaction, null);
|
|
await rejected(f, done, f.auths[0].playerId, decline);
|
|
await rejected(f, done, emperor, original);
|
|
await stationary(f, done);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('persisted Kull success races the final pass, retains printed Karama and blocks its phase activation', async () => {
|
|
const f = await persisted();
|
|
try {
|
|
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
|
|
const original = kullShipmentAttempt(f.initial, emperor);
|
|
const { g: offered, event } = await offer(f, f.initial, emperor, original);
|
|
let g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
|
|
assert.equal(g.response?.kind, 'choamWorthless');
|
|
assert.equal(g.karamaShipping, null);
|
|
assert.equal(g.discard.some(card => card.id === original.card), false);
|
|
await restored(f, g);
|
|
await stationary(f, g);
|
|
for (let step = 0; step < 4; step++) {
|
|
const eligible = g.players.filter(p => {
|
|
const controls = viewGame(g, p.id).responseControls;
|
|
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
|
});
|
|
assert.ok(eligible.length > 0, 'the final-pass race must reach an unresolved legal counter seat');
|
|
if (eligible.length === 1) break;
|
|
g = await act(f, g, eligible[0].id, { type: 'passResponse' });
|
|
}
|
|
const finalSeat = g.players.find(p => {
|
|
const controls = viewGame(g, p.id).responseControls;
|
|
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
|
})!;
|
|
const done = await compete(f, g, finalSeat.id, { type: 'passResponse' });
|
|
assert.equal(done.response, null);
|
|
assert.equal(viewGame(done, emperor).kullReaction, null);
|
|
assert.equal(done.karamaShipping, null);
|
|
assert.equal(done.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
|
|
assert.equal(player(done, emperor).hand.filter(card => card.id === original.card).length, 1);
|
|
assert.equal(player(done, emperor).spice, player(f.initial, emperor).spice);
|
|
await rejected(f, done, emperor, original);
|
|
await rejected(f, done, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
|
|
await stationary(f, done);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('a distinct held Karama counters persisted Kull and resumes the original shipment only once', async () => {
|
|
const f = await persisted(g => {
|
|
const distinct = player(g, g.players[3].id).hand.find(card => card.effect === 'karama')!;
|
|
player(g, g.players[1].id).hand.push(takeKullCard(g, distinct.id));
|
|
});
|
|
try {
|
|
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
|
|
const original = kullShipmentAttempt(f.initial, emperor);
|
|
const distinct = player(f.initial, emperor).hand.find(card => card.effect === 'karama' && card.id !== original.card)!;
|
|
const { g: offered, event } = await offer(f, f.initial, emperor, original);
|
|
const g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
|
|
const views = await restored(f, g);
|
|
assert.deepEqual(views[1].responseControls?.cancelCards, [distinct.id]);
|
|
assert.equal(views[1].responseControls?.cancelCards.includes(String(original.card)), false);
|
|
await rejected(f, g, emperor, { type: 'card', mode: 'cancel', card: original.card });
|
|
const counter = { type: 'card', mode: 'cancel', card: distinct.id };
|
|
const done = await compete(f, g, emperor, counter);
|
|
assert.deepEqual(done.karamaShipping, { player: emperor, owner: emperor, card: original.card });
|
|
assert.equal(done.response, null);
|
|
assert.equal(done.discard.filter(card => card.id === original.card).length, 1);
|
|
assert.equal(done.discard.filter(card => card.id === distinct.id).length, 1);
|
|
assert.equal(player(done, choam).hand.filter(card => card.id === 'ix-kull-wahad').length, 1);
|
|
assert.equal(done.discard.some(card => card.id === 'ix-kull-wahad'), false);
|
|
assert.equal(viewGame(done, emperor).kullReaction, null);
|
|
await rejected(f, done, emperor, counter);
|
|
await rejected(f, done, choam, { type: 'kullDecision', event, decline: true });
|
|
await stationary(f, done);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('Kull intercepts persisted BG substitution before conversion and retains every attempted physical card', async () => {
|
|
const f = await persisted(g => {
|
|
const extra = g.deck.find(card => card.kind === 'worthless')!;
|
|
player(g, g.players[2].id).hand.push(takeKullCard(g, extra.id));
|
|
});
|
|
try {
|
|
const bg = f.auths[2].playerId, emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
|
|
const worthless = player(f.initial, bg).hand.filter(card => card.kind === 'worthless');
|
|
assert.equal(worthless.length, 2);
|
|
const original = { type: 'card', mode: 'shipment', card: worthless[0].id, target: emperor };
|
|
const { g: offered, event } = await offer(f, f.initial, bg, original);
|
|
assert.equal(offered.pendingKarama, null);
|
|
let g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
|
|
assert.equal(g.response?.kind, 'choamWorthless');
|
|
assert.equal(g.pendingKarama, null);
|
|
assert.equal(player(g, bg).hand.some(card => card.id === worthless[0].id), true);
|
|
await restored(f, g);
|
|
await stationary(f, g);
|
|
g = await passes(f, g);
|
|
assert.equal(g.karamaShipping, null);
|
|
assert.deepEqual(player(g, bg).hand, player(f.initial, bg).hand);
|
|
assert.equal(g.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
|
|
assert.equal(g.discard.some(card => card.id === worthless[0].id), false);
|
|
await rejected(f, g, bg, original);
|
|
await rejected(f, g, bg, { ...original, card: worthless[1].id });
|
|
await stationary(f, g);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('declining persisted Kull resumes the real BG conversion and shipment without repeated disposal', async () => {
|
|
const f = await persisted();
|
|
try {
|
|
const bg = f.auths[2].playerId, emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
|
|
const worthless = player(f.initial, bg).hand.find(card => card.kind === 'worthless')!;
|
|
const original = { type: 'card', mode: 'shipment', card: worthless.id, target: emperor };
|
|
const { g: offered, event } = await offer(f, f.initial, bg, original);
|
|
const decline = { type: 'kullDecision', event, decline: true };
|
|
let g = await act(f, offered, choam, decline);
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
assert.ok(g.pendingKarama);
|
|
assert.equal(g.karamaShipping, null);
|
|
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
|
|
assert.equal(player(g, bg).hand.some(card => card.id === worthless.id), false);
|
|
await restored(f, g);
|
|
await stationary(f, g);
|
|
g = await passes(f, g);
|
|
assert.deepEqual(g.karamaShipping, { player: emperor, owner: bg, card: worthless.id });
|
|
assert.equal(g.pendingKarama, null);
|
|
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
|
|
assert.equal(player(g, choam).hand.filter(card => card.id === 'ix-kull-wahad').length, 1);
|
|
await rejected(f, g, choam, decline);
|
|
await rejected(f, g, bg, original);
|
|
await stationary(f, g);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('successful persisted Kull prevents Emperor revival before forces or its special once-use are spent', async () => {
|
|
const f = await persisted(g => {
|
|
g.phase = 4;
|
|
g.active = null;
|
|
player(g, g.players[1].id).tanks = 4;
|
|
player(g, g.players[1].id).reserves -= 4;
|
|
});
|
|
try {
|
|
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
|
|
const karama = player(f.initial, emperor).hand.find(card => card.effect === 'karama')!;
|
|
const original = { type: 'card', mode: 'special', card: karama.id, amount: 2 };
|
|
const { g: offered, event } = await offer(f, f.initial, emperor, original);
|
|
const declared = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
|
|
await restored(f, declared);
|
|
const done = await passes(f, declared);
|
|
assert.deepEqual(player(done, emperor), player(f.initial, emperor));
|
|
assert.equal(player(done, emperor).specialKaramaUsed ?? false, false);
|
|
assert.equal(done.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
|
|
assert.equal(done.discard.some(card => card.id === karama.id), false);
|
|
await rejected(f, done, emperor, original);
|
|
await stationary(f, done);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('a Kull-free saved CHOAM hand still receives the neutral private offer and can decline the real attempt', async () => {
|
|
const f = await persisted(g => { g.deck.push(takeKullCard(g, 'ix-kull-wahad')); });
|
|
try {
|
|
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
|
|
const original = kullShipmentAttempt(f.initial, emperor);
|
|
await rejected(f, f.initial, emperor, { type: 'card', mode: 'special', card: original.card, amount: 2 });
|
|
const { g, event } = await offer(f, f.initial, emperor, original, []);
|
|
await rejected(f, g, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
|
|
const done = await act(f, g, choam, { type: 'kullDecision', event, decline: true });
|
|
assert.deepEqual(done.karamaShipping, { player: emperor, owner: emperor, card: original.card });
|
|
assert.equal(done.discard.filter(card => card.id === original.card).length, 1);
|
|
assert.equal(done.discard.some(card => card.id === 'ix-kull-wahad'), false);
|
|
await stationary(f, done);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('corrupt saved Kull intent ownership cannot execute or write after a room-module restart', async () => {
|
|
const f = await persisted();
|
|
try {
|
|
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
|
|
const { g, event } = await offer(f, f.initial, emperor, kullShipmentAttempt(f.initial, emperor));
|
|
assert.ok(g.pendingKull);
|
|
const corrupt = structuredClone(g);
|
|
corrupt.pendingKull!.owner = f.auths[2].playerId;
|
|
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?')
|
|
.run(JSON.stringify(corrupt), f.code, g.version).changes, 1);
|
|
await rejected(f, corrupt, choam, { type: 'kullDecision', event, decline: true });
|
|
await rejected(f, corrupt, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
|
|
await assert.rejects(f.restart().readSeatView(f.code, f.auths[0]));
|
|
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('orphaned saved Kull counters cannot be projected or commit a partial pass', async () => {
|
|
for (const conversion of [false, true]) {
|
|
const f = await persisted();
|
|
try {
|
|
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
|
|
const { g, event } = await offer(f, f.initial, emperor, kullShipmentAttempt(f.initial, emperor));
|
|
let counter = await act(f, g, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
|
|
if (conversion) {
|
|
const bg = f.auths[2].playerId;
|
|
counter = await act(f, counter, bg, {
|
|
type: 'card', mode: 'cancel', card: player(counter, bg).hand[0].id,
|
|
});
|
|
}
|
|
const corrupt = structuredClone(counter);
|
|
delete corrupt.pendingKull;
|
|
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?')
|
|
.run(JSON.stringify(corrupt), f.code, counter.version).changes, 1);
|
|
await rejected(f, corrupt, f.auths[3].playerId, { type: 'passResponse' });
|
|
for (const auth of f.auths)
|
|
await assert.rejects(f.restart().readSeatView(f.code, auth), /lost its interrupted Karama frame/);
|
|
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
|
|
} finally { f.sqlite.close(); }
|
|
}
|
|
});
|