Files
Dune/tests/advanced-source-recovery.test.ts

601 lines
31 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, type GameView } from '../game/engine';
import { stormCardDistance, isStormCardDistance } from '../game/storm-cards';
import { createStormSource } from '../game/discovery-storm';
import type { FactionId } from '../game/catalog';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import type * as Rooms from '../db/rooms';
import { splitLocation, territory } from '../game/board';
import { unitStore } from './fixture-nexus-room-store';
import { createAdvancedSourceFixture, createRecordedAdvancedSourceFixture, advanceToForecast, advanceToNextStorm,
type AdvancedSourceFixtureOptions as Options, type AdvancedSourceFixture } from './fixture-advanced-source';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
type SqlRow = Record<string, SQLOutputValue>;
interface Fixture extends AdvancedSourceFixture {
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
initial: Game;
code: string;
tokens: string[];
auths: SeatAuth[];
save(g: Game): void;
credentialRows: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; recoveries: SqlRow[] };
unrelatedCode: string;
unrelatedToken: string;
unrelatedRow: SqlRow | undefined;
stock: string[];
forces: { id: string; total: number; elite: number }[];
}
function player(g: Game, id: string) {
const p = g.players.find(seat => seat.id === id);
assert.ok(p, `missing native seat ${id}`);
return p;
}
function credentials(sqlite: DatabaseSync) {
return {
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,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 forceStock(g: Game) {
return g.players.map(p => ({ id: p.id,
total: p.reserves + p.tanks + Object.values(p.forces).reduce((n, amount) => n + amount, 0),
elite: p.elites ? p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((n, amount) => n + amount, 0) : 0 }));
}
function cardStock(g: Game) {
const ids = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index) ?? [])].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'each physical Treachery card has one custodian');
return ids;
}
function receiptFaces(g: { log: readonly { component?: unknown }[] }) {
return g.log.flatMap(entry => {
const distance = stormCardDistance(entry.component);
return distance === null ? [] : [distance];
});
}
async function fixture(t: test.TestContext, options: Options = {}, recorded?: Game): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const factions: FactionId[] = recorded ? recorded.players.map(p => p.faction) :
options.alliance === 'ecaz' ? ['ecaz', 'fremen', 'atreides'] :
options.alliance === 'ownAdvisors' || options.alliance === 'allyAdvisors' ? ['atreides', 'beneGesserit', 'fremen'] :
options.alliance === 'firstEnding' || options.alliance === 'laterEnding' || options.alliance === 'newThisTurn' ?
['atreides', 'emperor', 'guild'] : options.alliance ? ['atreides', 'fremen', 'guild'] : ['atreides', 'emperor'];
if (!options.alliance && options.fremen) factions.push('fremen');
const made = await store.rooms.createRoom('Advanced source recovery', factions[0], options.advanced ?? true,
options.alliance === 'ecaz' ? ['ecaz'] : []);
const code = made.view.code, tokens = [made.token];
for (const faction of factions.slice(1)) tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
let auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
if (recorded) {
// Bind isolated credentials to the already native saved IDs, never rewrite
// the captured Game's identity or its historical source.
for (const [index, auth] of auths.entries())
assert.equal(store.sqlite.prepare('UPDATE seats SET player_id = ? WHERE room_code = ? AND token_hash = ?')
.run(recorded.players[index].id, code, auth.tokenHash).changes, 1);
auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
}
const native = recorded ? createRecordedAdvancedSourceFixture(recorded) :
createAdvancedSourceFixture({ ...options, seatIds: auths.map(auth => auth.playerId) });
const initial = native.game;
assert.deepEqual(initial.players.map(p => p.faction), factions, 'authenticated seats match the native constructor roster');
initial.code = code;
initial.host = auths[0].playerId;
initial.version = (await store.rooms.readRoom(code)).version;
const save = (g: Game) => {
assert.equal(store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code).changes, 1);
};
save(initial);
const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'harkonnen', false, []);
return { ...store, ...native, initial, code, tokens, auths, save,
credentialRows: credentials(store.sqlite), unrelatedCode: unrelated.view.code,
unrelatedToken: unrelated.token,
unrelatedRow: store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code),
stock: cardStock(initial), forces: forceStock(initial) };
}
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code);
function totalChanges(f: Fixture) {
const n = f.sqlite.prepare('SELECT total_changes() AS changes').get()?.changes;
assert.equal(typeof n, 'number');
return n as number;
}
function protectedRows(f: Fixture) {
assert.deepEqual(credentials(f.sqlite), f.credentialRows);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), f.unrelatedRow);
}
async function authenticate(f: Fixture, id: string) {
const index = f.auths.findIndex(auth => auth.playerId === id);
assert.ok(index >= 0);
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
assert.equal(auth.playerId, id);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
return auth;
}
async function act(f: Fixture, g: Game, id: string, action: Action) {
const changes = totalChanges(f);
await f.restart().act(f.code, await authenticate(f, id), g.version, action, clock);
assert.equal(totalChanges(f), changes + 1, 'one authenticated native action commits one room row');
protectedRows(f);
return f.restart().readRoom(f.code);
}
async function rejected(f: Fixture, g: Game, auth: SeatAuth, action: Action, version = g.version) {
const before = row(f), changes = totalChanges(f);
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
assert.deepEqual(row(f), before);
assert.equal(totalChanges(f), changes, 'rejected action writes no durable table');
protectedRows(f);
}
async function restored(f: Fixture, g: Game) {
const before = row(f), changes = totalChanges(f), original = plain(g);
assert.deepEqual(plain(await f.restart().readRoom(f.code)), original);
assert.deepEqual(plain(normalizeAutomaticGame(g)), original);
const views: GameView[] = [];
for (const auth of f.auths) {
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
for (const key of ['stormMovementSource', 'stormResolution', 'stormCard', 'stormCardKnown', 'stormDials'])
assert.equal(Object.hasOwn(view, key), false, `${key} stays outside the public projection`);
for (const rival of view.players.filter(p => p.id !== auth.playerId))
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
views.push(view);
}
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(plain(g), original, 'read and normalize leave their caller-owned input immutable');
assert.deepEqual(row(f), before);
assert.equal(totalChanges(f), changes, 'restart, reads and idle recovery perform no durable writes');
assert.deepEqual(cardStock(g), f.stock);
protectedRows(f);
return views;
}
async function readyStorm(f: Fixture, state: Game) {
let g = state;
for (const id of state.players.map(p => p.id)) g = await act(f, g, id, { type: 'ready' });
return g;
}
for (const advanced of [false, true]) {
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} first Storm preserves actual private dials across authenticated restart`, async t => {
const f = await fixture(t, { advanced, storm: 'first' });
let g = f.initial;
assert.equal(g.turn, 1);
assert.equal(g.stormDialers.length, 2);
assert.equal(g.stormPending, null);
await restored(f, g);
const [first, second] = g.stormDialers;
g = await act(f, g, first, { type: 'stormDial', amount: 2 });
for (const view of await restored(f, g)) {
assert.equal(view.stormRevealed, null);
assert.deepEqual(view.stormSubmitted, [first]);
assert.equal(view.stormForecast, null);
}
await rejected(f, g, await authenticate(f, first), { type: 'stormDial', amount: 3 });
await rejected(f, g, await authenticate(f, second), { type: 'stormDial', amount: 0 }, f.initial.version);
g = await act(f, g, second, { type: 'stormDial', amount: 0 });
assert.equal(g.stormPending, 2);
assert.equal(g.stormMovementSource?.kind, 'dials');
assert.equal(g.stormMovementSource?.distance, 2);
for (const view of await restored(f, g)) assert.deepEqual(view.stormRevealed, { [first]: 2, [second]: 0 });
const origin = g.storm;
g = await readyStorm(f, g);
assert.equal(g.storm, ((origin + 1) % 18) + 1);
assert.equal(g.phase, 1);
assert.equal(g.stormPending, null);
assert.deepEqual(receiptFaces(g), []);
await restored(f, g);
});
}
void test('SQLite subsequent Advanced Storm without Fremen reveals one canonical public card and traverses its saved distance once', async t => {
const f = await fixture(t, { storm: 'next' });
let g = f.initial;
assert.equal(g.turn, 2);
assert.equal(g.players.some(p => p.faction === 'fremen'), false);
assert.deepEqual(g.stormDialers, []);
assert.equal(g.stormCard, null, 'no absent faction receives a held private forecast');
assert.equal(g.stormCardKnown, false);
const face = g.stormPending;
assert.ok(isStormCardDistance(face));
assert.equal(g.stormMovementSource?.kind, 'card');
assert.equal(g.stormMovementSource?.distance, face);
assert.deepEqual(receiptFaces(g), [face]);
for (const view of await restored(f, g)) {
assert.deepEqual(view.stormDialers, []);
assert.equal(view.stormRevealed, null);
assert.equal(view.stormForecast, null);
assert.equal(view.stormPending, face);
assert.deepEqual(receiptFaces(view), [face]);
}
for (const auth of f.auths) await rejected(f, g, auth, { type: 'stormDial', amount: 0 });
const origin = g.storm;
g = await act(f, g, g.players[0].id, { type: 'ready' });
await restored(f, g);
assert.equal(g.stormPending, face);
assert.deepEqual(receiptFaces(g), [face]);
await rejected(f, g, f.auths[0], { type: 'ready' });
for (const p of g.players.slice(1)) g = await act(f, g, p.id, { type: 'ready' });
assert.equal(g.storm, ((origin + face - 1) % 18) + 1);
assert.equal(g.stormPending, null);
assert.equal(g.phase, 1);
assert.notEqual(g.response?.kind, 'stormPeek', 'there is no absent-Fremen forecast response or extra confirmation');
assert.equal(g.stormCard, null);
assert.equal(g.stormCardKnown, false);
assert.deepEqual(receiptFaces(g), [face]);
await restored(f, g);
await rejected(f, g, f.auths[0], { type: 'ready' }, f.initial.version);
});
void test('SQLite subsequent Basic Storm without Fremen retains its native wheel protocol', async t => {
const f = await fixture(t, { advanced: false, storm: 'next' });
let g = f.initial;
assert.equal(g.turn, 2);
assert.equal(g.stormPending, null);
assert.equal(g.stormDialers.length, 2);
assert.deepEqual(receiptFaces(g), []);
await restored(f, g);
for (const [index, id] of g.stormDialers.entries()) g = await act(f, g, id, { type: 'stormDial', amount: index + 1 });
assert.equal(g.stormPending, 3);
assert.equal(g.stormMovementSource?.kind, 'dials');
await restored(f, g);
const origin = g.storm;
g = await readyStorm(f, g);
assert.equal(g.storm, ((origin + 2) % 18) + 1);
assert.deepEqual(receiptFaces(g), []);
await restored(f, g);
});
void test('SQLite genuinely recorded Advanced wheel opening survives current restart, seals its original dials once and cuts over only on the next turn', async t => {
// Synthetic native capture generated from exact 8a56299155d0f12c8c4678bfbe5913be71fea5bb:
// createGame Advanced Atreides/Emperor, actual readiness/start and setup,
// then legal first-turn phase actions through the original next-Storm opener.
const captured = JSON.parse(readFileSync(fileURLToPath(new URL('./fixtures/advanced-recorded-storm-8a.json',
import.meta.url)), 'utf8')) as Game;
const original = plain(captured);
const f = await fixture(t, {}, captured);
assert.deepEqual(captured, original, 'the stored native capture remains caller-owned and immutable');
let g = f.initial;
assert.equal(g.advanced, true);
assert.equal(g.turn, 2);
assert.equal(g.phase, 0);
assert.equal(g.players.some(p => p.faction === 'fremen'), false);
assert.equal(g.players.find(p => p.faction === 'emperor')?.elites?.reserves, 5,
'the historical setup is genuinely Advanced, not a changed Basic flag');
assert.deepEqual(g.players, captured.players);
assert.deepEqual(g.stormDialers, captured.stormDialers);
assert.equal(g.stormPending, null);
assert.equal(g.stormMovementSource, undefined);
await restored(f, g);
const [first, second] = g.stormDialers;
g = await act(f, g, first, { type: 'stormDial', amount: 3 });
await restored(f, g);
assert.deepEqual(g.stormDialers, captured.stormDialers);
assert.deepEqual(g.stormDials, { [first]: 3 });
assert.equal(g.stormPending, null);
await rejected(f, g, await authenticate(f, first), { type: 'stormDial', amount: 1 });
await rejected(f, g, await authenticate(f, second), { type: 'stormDial', amount: 1 }, f.initial.version);
g = await act(f, g, second, { type: 'stormDial', amount: 1 });
assert.equal(g.stormPending, 4);
assert.equal(g.stormMovementSource?.kind, 'dials');
assert.equal(g.stormMovementSource?.distance, 4);
for (const view of await restored(f, g)) {
assert.deepEqual(view.stormRevealed, { [first]: 3, [second]: 1 });
assert.equal(view.stormForecast, null);
assert.deepEqual(receiptFaces(view), []);
}
const origin = g.storm;
g = await act(f, g, first, { type: 'ready' });
await restored(f, g);
g = await race(f, g, second, { type: 'ready' });
assert.equal(g.storm, ((origin + 3) % 18) + 1);
assert.equal(g.phase, 1);
assert.deepEqual(receiptFaces(g), []);
await restored(f, g);
await rejected(f, g, await authenticate(f, second), { type: 'ready' }, g.version - 1);
g = advanceToNextStorm(g);
f.save(g);
assert.equal(g.turn, 3);
assert.deepEqual(g.stormDialers, []);
assert.equal(g.stormMovementSource?.kind, 'card');
assert.ok(isStormCardDistance(g.stormPending));
assert.deepEqual(receiptFaces(g), [g.stormPending]);
for (const view of await restored(f, g)) {
assert.equal(view.stormRevealed, null);
assert.equal(view.stormForecast, null);
}
});
for (const cancel of [false, true]) {
void test(`SQLite native Fremen forecast remains private after ${cancel ? 'a genuine Karama counter' : 'all responses pass'} and supplies the next public card`, async t => {
const f = await fixture(t, { fremen: true, storm: 'first', karama: true });
let g = advanceToForecast(f.initial);
f.save(g);
assert.equal(g.response?.kind, 'stormPeek');
const face = g.stormCard;
assert.ok(isStormCardDistance(face));
const fremen = g.players.find(p => p.faction === 'fremen')!;
const counter = g.players.find(p => p.faction !== 'fremen' && p.hand.some(c => c.effect === 'karama'));
assert.ok(counter, 'the native constructor genuinely dealt a counter even when it will pass');
const card = counter.hand.find(c => c.effect === 'karama')!;
assert.ok(viewGame(g, counter.id).responseControls?.cancelCards.includes(card.id));
assert.deepEqual(forceStock(g), f.forces);
for (const view of await restored(f, g)) assert.equal(view.stormForecast, null);
if (cancel) {
g = await act(f, g, counter.id, { type: 'card', card: card.id, mode: 'cancel' });
} else {
while (g.response) {
const responder = g.players.find(p => !g.response!.passed.includes(p.id));
assert.ok(responder);
g = await act(f, g, responder.id, { type: 'passResponse' });
}
}
assert.equal(g.stormCard, face, 'a counter hides knowledge without replacing the physical upcoming card');
assert.equal(player(g, counter.id).hand.some(c => c.id === card.id), !cancel);
assert.equal(g.discard.filter(c => c.id === card.id).length, cancel ? 1 : 0);
assert.deepEqual(forceStock(g), f.forces);
for (const view of await restored(f, g)) {
assert.equal(view.stormForecast, !cancel && view.me === fremen.id ? face : null);
assert.deepEqual(receiptFaces(view), []);
}
g = advanceToNextStorm(g);
f.save(g);
assert.equal(g.stormPending, face);
assert.deepEqual(g.stormDialers, []);
assert.equal(g.stormCard, null);
assert.deepEqual(forceStock(g), f.forces);
for (const view of await restored(f, g)) {
assert.equal(view.stormForecast, null);
assert.deepEqual(receiptFaces(view), [face]);
}
});
}
async function race(f: Fixture, g: Game, id: string, action: Action) {
const auth = await authenticate(f, id), changes = totalChanges(f);
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
try {
const results = await Promise.allSettled([0, 1].map(() =>
f.restart().act(f.code, auth, g.version, action, clock).finally(release)));
assert.equal(arrivals, 2, 'both native submissions reach the SQLite version fence');
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
} finally { delete f.hooks.beforeWrite; }
assert.equal(totalChanges(f), changes + 1, 'only the CAS winner changes any durable row');
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, g.version + 1);
protectedRows(f);
return done;
}
void test('SQLite competing final Advanced card confirmations commit the saved distance once', async t => {
const f = await fixture(t, { storm: 'next' });
let g = f.initial;
const origin = g.storm, face = g.stormPending;
assert.ok(isStormCardDistance(face));
for (const p of g.players.slice(0, -1)) g = await act(f, g, p.id, { type: 'ready' });
await restored(f, g);
const actor = g.players[g.players.length - 1].id;
g = await race(f, g, actor, { type: 'ready' });
assert.equal(g.storm, ((origin + face - 1) % 18) + 1);
assert.equal(g.phase, 1);
assert.deepEqual(receiptFaces(g), [face]);
await restored(f, g);
await rejected(f, g, await authenticate(f, actor), { type: 'ready' }, g.version - 1);
});
function custody(g: Game, id: string) {
const p = player(g, id);
return { forces: p.forces, elites: p.elites ?? null, advisors: p.advisors ?? null,
reserves: p.reserves, tanks: p.tanks, noField: p.noField ?? null, hand: p.hand };
}
function lossCounts(g: Game, id: string, territories: readonly string[]) {
const p = player(g, id);
return {
total: Object.entries(p.forces).reduce((n, [key, amount]) =>
n + (territories.includes(splitLocation(key).territory) ? amount : 0), 0),
elite: Object.entries(p.elites?.forces ?? {}).reduce((n, [key, amount]) =>
n + (territories.includes(splitLocation(key).territory) ? amount : 0), 0),
};
}
for (const alliance of ['firstEnding', 'laterEnding'] as const) {
void test(`SQLite Advanced ${alliance} newly allied ending sends only that player's shared group to Tanks once through CAS`, async t => {
const f = await fixture(t, { alliance });
const before = f.initial, actor = player(before, f.actor);
assert.equal(before.phase, 5);
assert.equal(before.active, actor.id);
assert.ok(f.ally);
assert.equal(actor.ally, f.ally);
assert.equal(actor.allySinceTurn, before.turn, 'newly formed alliances receive the same mandatory ending consequence');
assert.ok(before.movementRemaining?.includes(f.ally), 'the ally has not reached its own ending yet');
assert.equal(before.order.indexOf(actor.id), alliance === 'firstEnding' ? 0 : 1,
'the later scenario genuinely follows a completed nonallied seat');
if (alliance === 'laterEnding') {
assert.ok(f.other);
assert.equal(before.movementRemaining?.includes(f.other), false);
}
const losses = lossCounts(before, actor.id, f.lossTerritories);
assert.ok(losses.total > 0, 'genuine native actions placed a shared fighter group');
assert.equal(actor.faction, 'emperor');
assert.equal(losses.elite, 1, 'the genuine paid shipment exercises elite classification within the physical loss');
const otherCustody = before.players.filter(p => p.id !== actor.id).map(p => [p.id, plain(custody(before, p.id))]);
const beforeTanks = actor.tanks, beforeEliteTanks = actor.elites?.tanks ?? 0;
const outside = Object.fromEntries(Object.entries(actor.forces).filter(([key]) =>
!f.lossTerritories.includes(splitLocation(key).territory)));
const views = await restored(f, before);
for (const view of views) assert.deepEqual(view.advancedAllySeparation,
view.me === actor.id ? { territories: f.lossTerritories.map(id => territory(id).name) } : null);
if (alliance === 'firstEnding') {
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
await rejected(f, before, { playerId: actor.id, tokenHash: foreign.tokenHash }, { type: 'endMovement' });
await rejected(f, before, await authenticate(f, f.ally), { type: 'endMovement' });
}
const done = await race(f, before, actor.id, { type: 'endMovement' });
const ended = player(done, actor.id);
assert.equal(ended.tanks, beforeTanks + losses.total);
assert.equal(ended.elites?.tanks ?? 0, beforeEliteTanks + losses.elite);
assert.deepEqual(lossCounts(done, actor.id, f.lossTerritories), { total: 0, elite: 0 });
assert.deepEqual(Object.fromEntries(Object.entries(ended.forces).filter(([key]) =>
!f.lossTerritories.includes(splitLocation(key).territory))), outside);
for (const [id, expected] of otherCustody) assert.deepEqual(custody(done, id as string), expected);
assert.deepEqual(forceStock(done), f.forces, 'casualties conserve physical and elite inventory');
assert.deepEqual(cardStock(done), f.stock);
await restored(f, done);
await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' });
await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' }, before.version);
assert.equal(player(await f.restart().readRoom(f.code), actor.id).tanks, beforeTanks + losses.total);
});
}
void test('SQLite native dealt Hajr retires without an early alliance casualty and the subsequent real ending loses the group once', async t => {
const f = await fixture(t, { alliance: 'firstEnding', hajr: true });
let g = f.initial;
const actor = player(g, f.actor), card = actor.hand.find(c => c.effect === 'hajr');
assert.ok(card, 'the actual native starting deal supplies Hajr to the ending actor');
assert.ok(f.ally);
const losses = lossCounts(g, actor.id, f.lossTerritories);
assert.ok(losses.total > 0);
const beforeForces = plain(actor.forces), beforeElite = plain(actor.elites), allyCustody = plain(custody(g, f.ally));
g = await act(f, g, actor.id, { type: 'card', card: card.id });
assert.equal(player(g, actor.id).tanks, actor.tanks);
assert.deepEqual(player(g, actor.id).forces, beforeForces);
assert.deepEqual(player(g, actor.id).elites, beforeElite);
assert.equal(player(g, actor.id).hand.some(c => c.id === card.id), false);
assert.equal(g.discard.filter(c => c.id === card.id).length, 1);
assert.equal(g.hajr.includes(actor.id), true);
await restored(f, g);
g = await race(f, g, actor.id, { type: 'endMovement' });
assert.equal(player(g, actor.id).tanks, actor.tanks + losses.total);
assert.deepEqual(lossCounts(g, actor.id, f.lossTerritories), { total: 0, elite: 0 });
assert.deepEqual(custody(g, f.ally), allyCustody);
assert.equal(g.discard.filter(c => c.id === card.id).length, 1);
assert.deepEqual(forceStock(g), f.forces);
await restored(f, g);
await rejected(f, g, await authenticate(f, actor.id), { type: 'card', card: card.id });
await rejected(f, g, await authenticate(f, actor.id), { type: 'endMovement' });
});
for (const advanced of [false, true]) {
for (const alliance of ['ownAdvisors', 'allyAdvisors', 'ecaz', 'polar'] as const) {
if (!advanced && (alliance === 'ownAdvisors' || alliance === 'allyAdvisors')) continue;
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${alliance} retains both allied groups on genuine native ending`, async t => {
const f = await fixture(t, { advanced, alliance });
const before = f.initial;
assert.equal(before.active, f.actor);
assert.ok(f.ally);
const shared = alliance === 'polar' ? 'polar_sink' : alliance === 'ecaz' ? 'imperial_basin' : 'arrakeen';
assert.ok(lossCounts(before, f.actor, [shared]).total > 0);
assert.ok(lossCounts(before, f.ally, [shared]).total > 0, 'the exception is exercised with actual co-present groups');
if (alliance === 'ownAdvisors' || alliance === 'allyAdvisors') {
const advisor = player(before, alliance === 'ownAdvisors' ? f.actor : f.ally);
assert.equal(advisor.faction, 'beneGesserit');
assert.ok(advisor.advisors?.[shared], 'actual BG advisor classification, not merely a BG faction');
}
const occupants = before.players.map(p => [p.id, plain(custody(before, p.id))]);
for (const view of await restored(f, before)) assert.equal(view.advancedAllySeparation, null);
const done = await act(f, before, f.actor, { type: 'endMovement' });
for (const [id, expected] of occupants) assert.deepEqual(custody(done, id as string), expected);
assert.deepEqual(forceStock(done), f.forces);
await restored(f, done);
await rejected(f, done, await authenticate(f, f.actor), { type: 'endMovement' }, before.version);
});
}
}
void test('SQLite Basic newly allied native movement retains the same-turn fighter group without an Advanced warning', async t => {
const f = await fixture(t, { advanced: false, alliance: 'newThisTurn' });
const actor = player(f.initial, f.actor);
assert.equal(actor.allySinceTurn, f.initial.turn);
assert.ok(lossCounts(f.initial, f.actor, f.lossTerritories).total > 0);
assert.ok(f.ally && lossCounts(f.initial, f.ally, f.lossTerritories).total > 0);
const before = plain(custody(f.initial, f.actor));
for (const view of await restored(f, f.initial)) assert.equal(view.advancedAllySeparation, null);
const done = await act(f, f.initial, f.actor, { type: 'endMovement' });
assert.deepEqual(custody(done, f.actor), before);
await restored(f, done);
});
void test('SQLite Basic old alliance loses only the later ending player after the native earlier ally has retained its group', async t => {
const f = await fixture(t, { advanced: false, alliance: 'laterEnding' });
const before = f.initial, actor = player(before, f.actor);
assert.ok(f.ally);
assert.ok((actor.allySinceTurn ?? before.turn) < before.turn);
assert.equal(before.movementRemaining?.includes(f.ally), false);
const losses = lossCounts(before, actor.id, f.lossTerritories);
assert.ok(losses.total > 0);
assert.ok(lossCounts(before, f.ally, f.lossTerritories).total > 0, 'the earlier ending ally genuinely retained its shared group');
const earlierCustody = plain(custody(before, f.ally));
await restored(f, before);
const done = await race(f, before, actor.id, { type: 'endMovement' });
assert.equal(player(done, actor.id).tanks, actor.tanks + losses.total);
assert.deepEqual(lossCounts(done, actor.id, f.lossTerritories), { total: 0, elite: 0 });
assert.deepEqual(custody(done, f.ally), earlierCustody);
assert.deepEqual(forceStock(done), f.forces);
await restored(f, done);
await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' });
});
void test('SQLite saved Advanced source actions reject foreign, forged-seat and stale credentials without changing unrelated rows', async t => {
const f = await fixture(t, { storm: 'next' });
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
const before = row(f), changes = totalChanges(f);
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
await assert.rejects(f.restart().readSeatView(f.code, foreign));
const forged = { playerId: f.auths[0].playerId, tokenHash: foreign.tokenHash };
await assert.rejects(f.restart().readSeatView(f.code, forged));
assert.equal(totalChanges(f), changes);
assert.deepEqual(row(f), before);
await rejected(f, f.initial, foreign, { type: 'ready' });
await rejected(f, f.initial, forged, { type: 'ready' });
await rejected(f, f.initial, f.auths[0], { type: 'ready' }, f.initial.version - 1);
await restored(f, f.initial);
});
for (const corrupt of [
{ name: 'altered source distance', change: (g: Game) => { g.stormMovementSource!.distance++; } },
{ name: 're-signed source from another turn', change: (g: Game) => {
g.stormMovementSource = createStormSource(g.turn - 1, 'card', g.stormPending!);
} },
{ name: 'orphaned pending distance', change: (g: Game) => { g.stormPending = g.stormPending === 6 ? 5 : 6; } },
{ name: 'lost original source', change: (g: Game) => { delete g.stormMovementSource; } },
{ name: 're-signed noncanonical card face', change: (g: Game) => {
g.stormPending = 7;
g.stormMovementSource = createStormSource(g.turn, 'card', 7);
} },
] as const) {
void test(`SQLite Advanced ${corrupt.name} rejects projection, normalization and action immutably with no durable writes`, async t => {
const f = await fixture(t, { storm: 'next' });
const broken = plain(f.initial);
corrupt.change(broken);
f.save(broken);
const original = plain(broken), before = row(f), changes = totalChanges(f);
assert.deepEqual(await f.restart().readRoom(f.code), original, 'raw restart preserves the corrupted record for rejection, not repair');
for (const auth of f.auths) {
assert.throws(() => viewGame(broken, auth.playerId));
await assert.rejects(f.restart().readSeatView(f.code, auth));
}
assert.throws(() => normalizeAutomaticGame(broken));
assert.throws(() => applyAction(broken, f.actor, { type: 'ready' }));
await rejected(f, broken, await authenticate(f, f.actor), { type: 'ready' });
assert.deepEqual(plain(broken), original);
assert.deepEqual(row(f), before);
assert.equal(totalChanges(f), changes);
protectedRows(f);
});
}