653 lines
38 KiB
TypeScript
653 lines
38 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
|
|
import { shuffle, viewGame, type Action, type Game } from '../game/engine';
|
|
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
|
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
|
import type * as Rooms from '../db/rooms';
|
|
import { startPrototypeRoom } from '../tools/prototype-room';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
import {
|
|
createHarkonnenNexusBetrayalFixture,
|
|
nextHarkonnenBetrayalNativeStep,
|
|
type HarkonnenBetrayalFixture,
|
|
type HarkonnenBetrayalFixtureOptions as Options,
|
|
} from './fixture-nexus-harkonnen-betrayal';
|
|
|
|
type SqlRow = Record<string, SQLOutputValue>;
|
|
interface Fixture extends HarkonnenBetrayalFixture {
|
|
sqlite: DatabaseSync;
|
|
restart(): typeof Rooms;
|
|
hooks: { beforeWrite?: () => Promise<void>; afterStatement?: (sql: string) => Promise<void> };
|
|
code: string;
|
|
tokens: string[];
|
|
auths: SeatAuth[];
|
|
save(game: Game): void;
|
|
protectedRows: Record<string, SqlRow[]>;
|
|
unrelatedCode: string;
|
|
unrelatedToken: string;
|
|
stock: string[];
|
|
traitorStock: string[];
|
|
setup: Game;
|
|
initial: Game;
|
|
}
|
|
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
|
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
|
|
|
|
/** Compare durable records, including credentials, lifecycle and recovery resources,
|
|
* rather than treating an attempted SQL update as a committed game action. */
|
|
function records(sqlite: DatabaseSync, excludedRoom?: string) {
|
|
const result: Record<string, SqlRow[]> = {};
|
|
for (const row of sqlite.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()) {
|
|
assert.equal(typeof row.name, 'string');
|
|
const table = String(row.name).replaceAll('"', '""');
|
|
const rows = sqlite.prepare(`SELECT * FROM "${table}"`).all();
|
|
result[String(row.name)] = rows.filter(value => !(row.name === 'rooms' && value.code === excludedRoom));
|
|
}
|
|
return result;
|
|
}
|
|
function physical(game: Game) {
|
|
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
|
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
|
|
const ids = cards.map(card => card.id).sort();
|
|
assert.equal(new Set(ids).size, ids.length, 'native Treachery cards have one physical custodian');
|
|
return ids;
|
|
}
|
|
function traitors(game: Game) {
|
|
const ids = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => [...player.traitors, ...player.traitorChoices])].sort();
|
|
assert.equal(new Set(ids).size, ids.length, 'each original physical Traitor Card has exactly one custodian');
|
|
return ids;
|
|
}
|
|
function player(game: Game, id: string) {
|
|
const value = game.players.find(player => player.id === id);
|
|
assert.ok(value);
|
|
return value;
|
|
}
|
|
function inventory(f: Fixture, game: Game) {
|
|
assert.deepEqual(physical(game), f.stock);
|
|
assert.equal(f.stock.length, 33);
|
|
assert.deepEqual(traitors(game), f.traitorStock);
|
|
assert.ok(game.nexusCards?.cards);
|
|
validateNexusCards(game.nexusCards.cards, game.players);
|
|
assert.deepEqual([...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard,
|
|
...Object.values(game.nexusCards.cards.hands).filter(face => face !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
|
assert.deepEqual(records(f.sqlite, f.code), f.protectedRows);
|
|
}
|
|
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, game: Game, id: string, action: Action) {
|
|
await f.restart().act(f.code, await authenticate(f, id), game.version, action, clock);
|
|
// The room API treats this native wake-up as read-only; its persisted worker
|
|
// performs any genuine pending discard continuation through the normal CAS.
|
|
if (action.type === 'advanceBots') await f.restart().continueRoomAutomatic(f.code, clock);
|
|
return f.restart().readRoom(f.code);
|
|
}
|
|
async function nativeCounters(f: Fixture, initial: Game) {
|
|
let game = initial;
|
|
for (let step = 0; game.response && step < 20; step++) {
|
|
const responder = game.players.find(player => !game.response!.passed.includes(player.id));
|
|
assert.ok(responder);
|
|
game = await act(f, game, responder.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(game.response, null);
|
|
return game;
|
|
}
|
|
async function openGate(f: Fixture, beforeCall: Game) {
|
|
let game = await act(f, beforeCall, f.provider, f.callAction);
|
|
if (f.remote) {
|
|
if (game.response) {
|
|
assert.equal(game.response.kind, 'harkonnenTraitor');
|
|
assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
|
|
} else {
|
|
assert.equal(game.pendingNexusHarkonnenBetrayal!.source.nativeWindow, 'harkonnenTraitor');
|
|
assert.equal(game.pendingNexusHarkonnenBetrayal!.source.nativeRequired.length, 0,
|
|
'Only a real counter-free native call may reach Nexus automatically.');
|
|
}
|
|
}
|
|
game = await nativeCounters(f, game);
|
|
assert.ok(game.pendingNexusHarkonnenBetrayal);
|
|
assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction!.event, f.event);
|
|
return game;
|
|
}
|
|
async function fixture(t: test.TestContext, options: Options & { counterKind?: 'printed' | 'BG Worthless' } = {}): Promise<Fixture> {
|
|
const store = unitStore();
|
|
t.after(() => store.sqlite.close());
|
|
const made = await store.rooms.createRoom('Native Harkonnen recovery', 'harkonnen', options.advanced ?? true, []);
|
|
const code = made.view.code;
|
|
const tokens = [made.token];
|
|
// BG's original prediction keeps the CLI-produced setup genuinely undealt;
|
|
// the fixture then consumes that native setup with its scoped deterministic RNG.
|
|
const factions = options.counterKind === 'printed' ? ['atreides', 'emperor'] as const
|
|
: ['atreides', 'emperor', 'beneGesserit'] as const;
|
|
for (const faction of factions) {
|
|
const joined = await store.rooms.joinRoom(code, faction, faction);
|
|
assert.ok(joined.token);
|
|
tokens.push(joined.token);
|
|
}
|
|
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
|
let admitted = await store.rooms.readRoom(code);
|
|
for (const auth of auths) {
|
|
await store.rooms.act(code, auth, admitted.version, { type: 'ready' }, clock);
|
|
admitted = await store.rooms.readRoom(code);
|
|
}
|
|
// Main recovery scenarios continue the exact CLI-produced undealt setup.
|
|
// Counter scenarios retain the authenticated fresh lobby so the native fixture
|
|
// can conserve and order its first actual deal; no started custody is redealt.
|
|
if (!options.counterKind) startPrototypeRoom(store.sqlite, code, admitted.version, 'harkonnen-betrayal');
|
|
const setup = await store.rooms.readRoom(code);
|
|
const native = createHarkonnenNexusBetrayalFixture({ advanced: options.advanced, remote: options.remote,
|
|
receiverCount: options.receiverCount, face: options.face, initial: setup });
|
|
assert.equal(native.beforeCall.code, code);
|
|
assert.equal(native.beforeCall.host, admitted.host);
|
|
assert.equal(native.beforeCall.advanced, admitted.advanced);
|
|
assert.deepEqual(native.beforeCall.expansions, []);
|
|
assert.deepEqual(native.beforeCall.playerPositions, admitted.playerPositions);
|
|
assert.deepEqual(native.beforeCall.players.map(player => [player.id, player.name, player.faction]),
|
|
admitted.players.map(player => [player.id, player.name, player.faction]));
|
|
const save = (game: Game) => {
|
|
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(game), game.version, code).changes, 1);
|
|
};
|
|
save(native.beforeCall);
|
|
const unrelated = await store.rooms.createRoom('Unrelated preserved lobby', 'guild', false, []);
|
|
const f: Fixture = { ...store, ...native, code, tokens, auths, save,
|
|
protectedRows: records(store.sqlite, code), unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token,
|
|
stock: physical(native.beforeCall), traitorStock: traitors(native.beforeCall), initial: native.beforeCall, setup };
|
|
f.initial = await openGate(f, native.beforeCall);
|
|
return f;
|
|
}
|
|
async function rejected(f: Fixture, game: Game, auth: SeatAuth, action: Action, version = game.version) {
|
|
const before = records(f.sqlite);
|
|
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
|
|
assert.deepEqual(records(f.sqlite), before, 'rejection changes no durable game, credential or resource record');
|
|
}
|
|
async function restored(f: Fixture, game: Game) {
|
|
const before = records(f.sqlite);
|
|
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(game));
|
|
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(game, auth.playerId)));
|
|
for (const key of ['pendingNexusHarkonnenBetrayal', 'nexusHarkonnenBetrayalCursor', 'nexusHarkonnenBetrayalHistory',
|
|
'pendingNexusHarkonnenReplacement', 'nexusHarkonnenReplacementHistory', 'traitorReserve'])
|
|
assert.equal(Object.hasOwn(view, key), false, `${key} stays private`);
|
|
for (const rival of view.players.filter(player => player.id !== auth.playerId))
|
|
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
|
|
const reaction = view.nexusHarkonnenBetrayalReaction;
|
|
if (reaction) {
|
|
assert.deepEqual(Object.keys(reaction).sort(), ['event', 'provider', 'beneficiary', 'target', 'leader', 'identity', 'canPass', 'hasPassed', 'canUse', 'blocked'].sort());
|
|
assert.equal(reaction.provider, f.provider);
|
|
assert.equal(reaction.beneficiary, f.beneficiary);
|
|
assert.equal(reaction.target, f.target);
|
|
assert.equal(reaction.identity, f.identity, 'only the genuine public declaration is revealed');
|
|
if (!f.required.includes(auth.playerId)) {
|
|
assert.equal(reaction.canPass, false);
|
|
assert.equal(reaction.canUse, false);
|
|
assert.equal(reaction.blocked, null);
|
|
}
|
|
}
|
|
}
|
|
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
|
assert.deepEqual(records(f.sqlite), before, 'refresh/recovery never consumes a pending response or redraws a finished replacement');
|
|
inventory(f, game);
|
|
}
|
|
async function closeGate(f: Fixture, initial: Game, used: boolean) {
|
|
if (used) return act(f, initial, f.holder, { type: 'nexusHarkonnenBetrayalUse', event: f.event });
|
|
let game = initial;
|
|
for (const id of f.required) game = await act(f, game, id, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
|
return game;
|
|
}
|
|
function settled(f: Fixture, game: Game, used: boolean) {
|
|
assert.equal(game.pendingNexusHarkonnenBetrayal ?? null, null);
|
|
assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
|
|
const history = game.nexusHarkonnenBetrayalHistory!;
|
|
assert.equal(history.length, (f.initial.nexusHarkonnenBetrayalHistory?.length ?? 0) + 1);
|
|
const receipt = history.at(-1)!;
|
|
assert.deepEqual(receipt.source, f.initial.pendingNexusHarkonnenBetrayal!.source);
|
|
assert.equal(receipt.outcome, used ? 'use' : 'pass');
|
|
assert.equal(receipt.holder, used ? f.holder : null);
|
|
assert.equal(game.nexusHarkonnenBetrayalCursor!.sequence, f.initial.nexusHarkonnenBetrayalCursor!.sequence + 1);
|
|
assert.equal(game.nexusCards!.cards!.hands[f.holder], used ? null : f.initial.nexusCards!.cards!.hands[f.holder]);
|
|
assert.deepEqual(game.nexusCards!.cards!.discard, [...f.initial.nexusCards!.cards!.discard, ...(used ? ['harkonnen'] : [])]);
|
|
assert.deepEqual(player(game, f.provider).traitors, used
|
|
? player(f.initial, f.provider).traitors.filter(identity => identity !== f.identity)
|
|
: player(f.initial, f.provider).traitors);
|
|
if (used) {
|
|
assert.deepEqual([...(game.traitorReserve ?? [])].sort(), [...(f.initial.traitorReserve ?? []), f.identity].sort());
|
|
assert.equal(player(game, f.provider).revealedTraitors?.includes(f.identity) ?? false, false);
|
|
assert.equal(game.pendingNexusHarkonnenReplacement!.status, 'due');
|
|
assert.equal(game.pendingNexusHarkonnenReplacement!.identity, f.identity);
|
|
assert.equal(game.pendingNexusHarkonnenReplacement!.provider, f.provider);
|
|
assert.equal(game.pendingNexusHarkonnenReplacement!.turn, f.initial.turn);
|
|
assert.equal(game.pendingNexusHarkonnenReplacement!.drawn, null);
|
|
assert.equal(game.battle?.traitorCalls[f.provider], false, 'the original call is canceled, not replayed');
|
|
} else {
|
|
assert.equal(game.pendingNexusHarkonnenReplacement ?? null, null);
|
|
assert.deepEqual(game.traitorReserve, f.initial.traitorReserve);
|
|
}
|
|
inventory(f, game);
|
|
}
|
|
async function mentat(f: Fixture, initial: Game, raceRecovery = false) {
|
|
let game = initial;
|
|
for (let step = 0; game.phase !== 8 && step < 80; step++) {
|
|
const next = nextHarkonnenBetrayalNativeStep(game);
|
|
const before = game;
|
|
if (raceRecovery) {
|
|
await Promise.all([act(f, before, next.actor, next.action),
|
|
f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
|
game = await f.restart().readRoom(f.code);
|
|
} else game = await act(f, before, next.actor, next.action);
|
|
}
|
|
assert.equal(game.phase, 8, 'actual battle resolution, Collection and native readiness reach Mentat');
|
|
assert.equal(game.turn, initial.turn);
|
|
assert.equal(game.battle, null);
|
|
return game;
|
|
}
|
|
|
|
for (const advanced of [false, true]) for (const remote of [false, true]) for (const used of [false, true]) {
|
|
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${remote ? 'allied' : 'personal'} Harkonnen ${used ? 'Use' : 'all-pass'} continues the original battle and actual Mentat once`, async t => {
|
|
const f = await fixture(t, { advanced, remote });
|
|
await restored(f, f.initial);
|
|
const done = await closeGate(f, f.initial, used);
|
|
settled(f, done, used);
|
|
await restored(f, done);
|
|
const expectedDraw = done.traitorReserve?.[0];
|
|
const previousHeld = [...player(done, f.provider).traitors];
|
|
const pause = await mentat(f, done, true);
|
|
assert.equal(pause.pendingNexusHarkonnenReplacement ?? null, null);
|
|
if (used) {
|
|
const drawn = pause.nexusHarkonnenReplacementHistory!.at(-1)!;
|
|
assert.equal(drawn.status, 'drawn');
|
|
assert.equal(drawn.event, f.event);
|
|
assert.equal(drawn.drawn, expectedDraw);
|
|
assert.deepEqual(player(pause, f.provider).traitors, [...previousHeld, expectedDraw]);
|
|
assert.deepEqual(pause.traitorReserve, done.traitorReserve!.slice(1));
|
|
assert.equal(pause.nexusHarkonnenReplacementHistory!.length, (done.nexusHarkonnenReplacementHistory?.length ?? 0) + 1);
|
|
} else {
|
|
assert.deepEqual(pause.nexusHarkonnenReplacementHistory, done.nexusHarkonnenReplacementHistory);
|
|
assert.deepEqual(player(pause, f.provider).traitors, previousHeld);
|
|
}
|
|
await restored(f, pause);
|
|
for (const type of ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const)
|
|
await rejected(f, pause, await authenticate(f, f.holder), { type, event: f.event });
|
|
});
|
|
}
|
|
|
|
void test('SQLite lost successful Use response restores the exact committed shuffle and cannot spend or schedule twice', async t => {
|
|
const f = await fixture(t, { remote: true });
|
|
let lost = false;
|
|
f.hooks.afterStatement = async sql => {
|
|
if (sql.startsWith('UPDATE rooms SET state') && !lost) { lost = true; throw new Error('lost response after committed CAS'); }
|
|
};
|
|
try {
|
|
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), f.initial.version,
|
|
{ type: 'nexusHarkonnenBetrayalUse', event: f.event }, clock), /lost response/);
|
|
} finally { delete f.hooks.afterStatement; }
|
|
const committed = await f.restart().readRoom(f.code);
|
|
assert.equal(committed.version, f.initial.version + 1);
|
|
settled(f, committed, true);
|
|
await restored(f, committed);
|
|
await rejected(f, committed, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event }, f.initial.version);
|
|
await rejected(f, committed, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event });
|
|
});
|
|
|
|
void test('SQLite actor credentials, revoked sessions and event-only selectors fence private state without durable changes', async t => {
|
|
const f = await fixture(t);
|
|
const holder = await authenticate(f, f.holder);
|
|
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
|
|
const use: Action = { type: 'nexusHarkonnenBetrayalUse', event: f.event };
|
|
const before = records(f.sqlite);
|
|
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
|
|
await assert.rejects(f.restart().readSeatView(f.code, foreign));
|
|
await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.holder, tokenHash: foreign.tokenHash }));
|
|
assert.deepEqual(records(f.sqlite), before);
|
|
for (const auth of [foreign, { playerId: f.holder, tokenHash: foreign.tokenHash }, await authenticate(f, f.provider)])
|
|
await rejected(f, f.initial, auth, use);
|
|
await rejected(f, f.initial, holder, use, f.initial.version - 1);
|
|
for (const extra of [{ actor: f.provider }, { player: f.provider }, { provider: f.provider }, { holder: f.holder },
|
|
{ identity: f.identity }, { leader: f.identity }, { target: f.target }, { card: 'harkonnen' }, { event: `${f.event}:stale` }, { replacement: f.identity }])
|
|
for (const type of ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const)
|
|
await rejected(f, f.initial, holder, { ...use, type, ...extra } as Action);
|
|
for (const action of [f.callAction, { type: 'passResponse' }, { type: 'ready' }, { type: 'traitorCall', call: false }, { type: 'nexusCard', choice: 'replace' }])
|
|
await rejected(f, f.initial, await authenticate(f, f.provider), action as Action);
|
|
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
|
|
f.protectedRows = records(f.sqlite, f.code);
|
|
await assert.rejects(f.restart().readSeatView(f.code, holder));
|
|
await rejected(f, f.initial, holder, use);
|
|
});
|
|
|
|
void test('SQLite neutral rival acknowledgments are identical for genuine relevant and irrelevant natural Nexus draws', async t => {
|
|
const f = await fixture(t);
|
|
const irrelevantFixture = createHarkonnenNexusBetrayalFixture({ initial: f.setup, face: 'richese' });
|
|
const other: Fixture = { ...f, ...irrelevantFixture };
|
|
f.save(irrelevantFixture.beforeCall);
|
|
const irrelevant = await openGate(other, irrelevantFixture.beforeCall);
|
|
other.initial = irrelevant;
|
|
for (const auth of f.auths) {
|
|
const original = viewGame(f.initial, auth.playerId);
|
|
const changed = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
|
if (auth.playerId !== f.holder) {
|
|
const originalReaction = original.nexusHarkonnenBetrayalReaction!;
|
|
const changedReaction = changed.nexusHarkonnenBetrayalReaction!;
|
|
assert.deepEqual({ ...changedReaction, event: null }, { ...originalReaction, event: null });
|
|
assert.deepEqual(changed.players.map(player => [player.id, player.revealedTraitors]),
|
|
original.players.map(player => [player.id, player.revealedTraitors]));
|
|
assert.deepEqual(changed.nexusCards, original.nexusCards);
|
|
}
|
|
else {
|
|
assert.equal(changed.nexusHarkonnenBetrayalReaction!.canPass, true);
|
|
assert.equal(changed.nexusHarkonnenBetrayalReaction!.canUse, false);
|
|
}
|
|
}
|
|
await restored(other, irrelevant);
|
|
await rejected(other, irrelevant, await authenticate(other, other.holder), { type: 'nexusHarkonnenBetrayalUse', event: other.event });
|
|
const done = await closeGate(other, irrelevant, false);
|
|
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
|
|
assert.deepEqual(done.nexusCards!.cards!.discard, irrelevant.nexusCards!.cards!.discard);
|
|
inventory(f, done);
|
|
});
|
|
|
|
void test('SQLite two public receivers restore partial Pass, retain private faces and reject duplicate/stale acknowledgement', async t => {
|
|
const f = await fixture(t, { receiverCount: 2 });
|
|
assert.equal(f.required.length, 2);
|
|
const firstId = f.required.find(id => id !== f.holder)!;
|
|
const first = await act(f, f.initial, firstId, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
|
assert.deepEqual(first.pendingNexusHarkonnenBetrayal!.passed, [firstId]);
|
|
assert.equal(viewGame(first, firstId).nexusHarkonnenBetrayalReaction!.hasPassed, true);
|
|
assert.equal(viewGame(first, f.holder).active, f.holder);
|
|
assert.deepEqual(first.nexusCards!.cards, f.initial.nexusCards!.cards);
|
|
assert.deepEqual(first.nexusHarkonnenBetrayalHistory, f.initial.nexusHarkonnenBetrayalHistory);
|
|
await restored(f, first);
|
|
await rejected(f, first, await authenticate(f, firstId), { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
|
await rejected(f, first, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event }, f.initial.version);
|
|
const done = await closeGate(f, first, true);
|
|
settled(f, done, true);
|
|
});
|
|
|
|
for (const race of ['use/pass', 'use/use', 'two passes'] as const) {
|
|
void test(`SQLite concurrent ${race} commits one source transition and restores the losing actor's fresh continuation`, async t => {
|
|
const f = await fixture(t, { receiverCount: race === 'two passes' ? 2 : 1 });
|
|
let arrivals = 0;
|
|
let release!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
|
const ids = race === 'two passes' ? f.required : [f.holder, f.holder];
|
|
const auths = await Promise.all(ids.map(id => authenticate(f, id)));
|
|
const types = race === 'two passes' ? ['nexusHarkonnenBetrayalPass', 'nexusHarkonnenBetrayalPass'] as const
|
|
: race === 'use/use' ? ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalUse'] as const
|
|
: ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const;
|
|
let results: PromiseSettledResult<unknown>[];
|
|
try {
|
|
results = await Promise.allSettled(auths.map((auth, index) => f.restart().act(f.code, auth, f.initial.version,
|
|
{ type: types[index], event: f.event }, clock).finally(release)));
|
|
} finally { delete f.hooks.beforeWrite; }
|
|
assert.equal(arrivals, 2);
|
|
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
|
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
|
let done = await f.restart().readRoom(f.code);
|
|
assert.equal(done.version, f.initial.version + 1);
|
|
if (race === 'two passes') {
|
|
const winner = results.findIndex(result => result.status === 'fulfilled');
|
|
assert.deepEqual(done.pendingNexusHarkonnenBetrayal!.passed, [ids[winner]]);
|
|
await restored(f, done);
|
|
await rejected(f, done, auths[winner], { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
|
done = await act(f, done, ids[1 - winner], { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
|
settled(f, done, false);
|
|
} else settled(f, done, race === 'use/use' || results[0].status === 'fulfilled');
|
|
await restored(f, done);
|
|
});
|
|
}
|
|
|
|
async function corruptRejected(f: Fixture, corrupt: Game, name: string) {
|
|
f.save(corrupt);
|
|
const before = records(f.sqlite);
|
|
const original = plain(corrupt);
|
|
for (const auth of f.auths)
|
|
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
|
|
for (const type of ['nexusHarkonnenBetrayalPass', 'nexusHarkonnenBetrayalUse'] as const)
|
|
await rejected(f, corrupt, await authenticate(f, f.holder), { type, event: f.event });
|
|
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
|
assert.deepEqual(records(f.sqlite), before, `${name}: read/action/normalize never repairs or consumes corrupt saves`);
|
|
assert.deepEqual(corrupt, original);
|
|
}
|
|
|
|
for (const remote of [false, true]) {
|
|
void test(`SQLite ${remote ? 'allied' : 'personal'} committed source and physical custody corruption is immutable across authenticated read/action/recovery`, async t => {
|
|
const f = await fixture(t, remote ? { remote: true, counterKind: 'printed' } : { remote: false });
|
|
if (remote) assert.ok(f.initial.pendingNexusHarkonnenBetrayal!.source.nativePassed.length,
|
|
'The lost-pass mutation requires an actual completed native counter window.');
|
|
const alterations: { name: string; change: (game: Game) => void }[] = [
|
|
{ name: 'missing gate', change: game => { delete game.pendingNexusHarkonnenBetrayal; } },
|
|
{ name: 'missing cursor', change: game => { delete game.nexusHarkonnenBetrayalCursor; } },
|
|
{ name: 'missing history', change: game => { delete game.nexusHarkonnenBetrayalHistory; } },
|
|
{ name: 'cursor sequence', change: game => { game.nexusHarkonnenBetrayalCursor!.sequence++; } },
|
|
{ name: 'source parent', change: game => { game.pendingNexusHarkonnenBetrayal!.source.parent += ':forged'; } },
|
|
{ name: 'native counter context', change: game => { game.pendingNexusHarkonnenBetrayal!.source.nativeContext += ':forged'; } },
|
|
{ name: 'native window', change: game => { game.pendingNexusHarkonnenBetrayal!.source.nativeWindow = remote ? 'direct' : 'harkonnenTraitor'; } },
|
|
{ name: 'source provider', change: game => { game.pendingNexusHarkonnenBetrayal!.source.provider = f.holder; } },
|
|
{ name: 'matched identity', change: game => { game.pendingNexusHarkonnenBetrayal!.source.declaration.identity = game.traitorReserve![0]; } },
|
|
{ name: 'battle event', change: game => { game.battle!.event += ':forged'; } },
|
|
{ name: 'beneficiary role', change: game => { game.pendingNexusHarkonnenBetrayal!.source.declaration.beneficiary = f.holder; } },
|
|
{ name: 'required membership', change: game => { game.pendingNexusHarkonnenBetrayal!.required = [f.provider]; } },
|
|
{ name: 'invented pass', change: game => { game.pendingNexusHarkonnenBetrayal!.passed = [f.provider]; } },
|
|
{ name: 'public declaration', change: game => { game.battle!.traitorDeclarations![f.provider].signature += ':forged'; } },
|
|
{ name: 'missing called physical traitor', change: game => {
|
|
player(game, f.provider).traitors = player(game, f.provider).traitors.filter(identity => identity !== f.identity);
|
|
game.traitorReserve!.push(f.identity);
|
|
} },
|
|
{ name: 'duplicate Traitor identity', change: game => { game.traitorReserve!.push(f.identity); } },
|
|
{ name: 'missing Traitor identity', change: game => { game.traitorReserve!.pop(); } },
|
|
{ name: 'duplicate Nexus', change: game => { game.nexusCards!.cards!.deck.push('harkonnen'); } },
|
|
{ name: 'mutated committed Nexus face', change: game => {
|
|
const cards = game.nexusCards!.cards!;
|
|
const index = cards.deck.indexOf('richese');
|
|
assert.ok(index >= 0);
|
|
cards.hands[f.holder] = 'richese';
|
|
cards.deck[index] = 'harkonnen';
|
|
} },
|
|
{ name: 'missing base Treachery', change: game => { game.deck.pop(); } },
|
|
{ name: 'canonical Treachery face', change: game => { game.deck[0].name += ' forged'; } },
|
|
{ name: 'lost preview boundary', change: game => { delete game.nexusHarkonnenBetrayalPreview; } },
|
|
{ name: 'native faction changed', change: game => { player(game, f.provider).faction = 'guild'; } },
|
|
{ name: 'foreign expansion', change: game => { game.expansions.push('ix'); } },
|
|
{ name: 'wrong phase', change: game => { game.phase++; } },
|
|
{ name: 'wrong turn', change: game => { game.turn++; } },
|
|
...(remote ? [{ name: 'lost original counter passes', change: (game: Game) => {
|
|
game.pendingNexusHarkonnenBetrayal!.source.nativePassed = [];
|
|
} }] : []),
|
|
];
|
|
for (const { name, change } of alterations) {
|
|
const corrupt = structuredClone(f.initial);
|
|
change(corrupt);
|
|
await corruptRejected(f, corrupt, name);
|
|
}
|
|
});
|
|
}
|
|
|
|
void test('SQLite exact successful Use obligation restores before Mentat, privately draws once and rejects corrupted late provenance', async t => {
|
|
const f = await fixture(t, { remote: true });
|
|
const due = await closeGate(f, f.initial, true);
|
|
await restored(f, due);
|
|
const pending = due.pendingNexusHarkonnenReplacement!;
|
|
const dueAlterations: { name: string; change: (game: Game) => void }[] = [
|
|
{ name: 'missing obligation', change: game => { game.pendingNexusHarkonnenReplacement = null; } },
|
|
{ name: 'replacement wrong provider', change: game => { game.pendingNexusHarkonnenReplacement!.provider = f.holder; } },
|
|
{ name: 'replacement wrong turn', change: game => { game.pendingNexusHarkonnenReplacement!.turn++; } },
|
|
{ name: 'replacement wrong identity', change: game => { game.pendingNexusHarkonnenReplacement!.identity = game.traitorReserve!.find(identity => identity !== f.identity)!; } },
|
|
{ name: 'replacement receipt link', change: game => { game.pendingNexusHarkonnenReplacement!.receipt += ':forged'; } },
|
|
{ name: 'premature replacement', change: game => {
|
|
game.pendingNexusHarkonnenReplacement!.status = 'drawn';
|
|
game.pendingNexusHarkonnenReplacement!.drawn = game.traitorReserve![0];
|
|
} },
|
|
{ name: 'closed history link', change: game => { game.nexusHarkonnenBetrayalHistory!.at(-1)!.previous += ':forged'; } },
|
|
{ name: 'closed declaration source', change: game => { game.nexusHarkonnenBetrayalHistory!.at(-1)!.source.nativeContext += ':forged'; } },
|
|
];
|
|
for (const { name, change } of dueAlterations) {
|
|
const corrupt = structuredClone(due);
|
|
change(corrupt);
|
|
await corruptRejected(f, corrupt, name);
|
|
}
|
|
f.save(due);
|
|
let game = due;
|
|
for (let count = 0; game.phase !== 7 && count < 80; count++) {
|
|
const step = nextHarkonnenBetrayalNativeStep(game);
|
|
game = await act(f, game, step.actor, step.action);
|
|
}
|
|
assert.equal(game.phase, 7, 'the original battle reaches actual Collection without changing the due obligation');
|
|
await restored(f, game);
|
|
assert.deepEqual(game.pendingNexusHarkonnenReplacement, pending);
|
|
const pause = await mentat(f, game, true);
|
|
assert.equal(pause.pendingNexusHarkonnenReplacement, null);
|
|
const drawn = pause.nexusHarkonnenReplacementHistory!.at(-1)!;
|
|
assert.equal(drawn.receipt, pending.receipt);
|
|
assert.equal(drawn.drawn, game.traitorReserve![0]);
|
|
const providerView = await f.restart().readSeatView(f.code, await authenticate(f, f.provider));
|
|
assert.deepEqual(providerView.players.find(player => player.id === f.provider)!.traitors, player(pause, f.provider).traitors);
|
|
for (const auth of f.auths.filter(auth => auth.playerId !== f.provider)) {
|
|
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
|
assert.equal(Object.hasOwn(view.players.find(player => player.id === f.provider)!, 'traitors'), false);
|
|
assert.equal(Object.hasOwn(view, 'nexusHarkonnenReplacementHistory'), false);
|
|
}
|
|
await restored(f, pause);
|
|
for (const change of [
|
|
(game: Game) => { game.nexusHarkonnenReplacementHistory = []; },
|
|
(game: Game) => { game.nexusHarkonnenReplacementHistory!.push(structuredClone(drawn)); },
|
|
(game: Game) => { game.nexusHarkonnenReplacementHistory!.at(-1)!.drawn = 'unknown-physical-identity'; },
|
|
(game: Game) => { game.nexusHarkonnenReplacementHistory!.at(-1)!.receipt += ':forged'; },
|
|
(game: Game) => { game.pendingNexusHarkonnenReplacement = structuredClone(pending); },
|
|
(game: Game) => { game.nexusHarkonnenBetrayalHistory = []; },
|
|
(game: Game) => { game.nexusHarkonnenBetrayalCursor!.signature += ':forged'; },
|
|
]) {
|
|
const corrupt = structuredClone(pause);
|
|
change(corrupt);
|
|
await corruptRejected(f, corrupt, 'completed replacement provenance');
|
|
}
|
|
});
|
|
|
|
void test('SQLite Use shuffles the exact retired physical identity and permits its genuine same-card Mentat redraw once', async t => {
|
|
const f = await fixture(t);
|
|
const sourceReserve = [...f.initial.traitorReserve!, f.identity];
|
|
let quoteIndex = 0;
|
|
const expected = shuffle(sourceReserve, () => quoteIndex++ === 0 ? 0 : 0xffffffff / 4294967296);
|
|
assert.equal(expected[0], f.identity, 'deterministic real shuffle places the retired identity at the actual deck top');
|
|
const descriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'getRandomValues');
|
|
let randomIndex = 0;
|
|
Object.defineProperty(globalThis.crypto, 'getRandomValues', {
|
|
configurable: true,
|
|
value: <T extends ArrayBufferView | null>(array: T): T => {
|
|
assert.ok(array instanceof Uint32Array);
|
|
for (let index = 0; index < array.length; index++) array[index] = randomIndex++ === 0 ? 0 : 0xffffffff;
|
|
return array;
|
|
},
|
|
});
|
|
let due: Game;
|
|
try {
|
|
due = await closeGate(f, f.initial, true);
|
|
} finally {
|
|
if (descriptor) Object.defineProperty(globalThis.crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(globalThis.crypto, 'getRandomValues');
|
|
}
|
|
assert.deepEqual(due.traitorReserve, expected, 'Use performs the native shuffle on reserve plus exactly the called physical card');
|
|
assert.deepEqual(player(due, f.provider).traitors, player(f.initial, f.provider).traitors.filter(identity => identity !== f.identity));
|
|
await restored(f, due);
|
|
const pause = await mentat(f, due, true);
|
|
assert.equal(pause.nexusHarkonnenReplacementHistory!.at(-1)!.drawn, f.identity);
|
|
assert.equal(player(pause, f.provider).traitors.filter(identity => identity === f.identity).length, 1);
|
|
assert.deepEqual(pause.traitorReserve, expected.slice(1));
|
|
assert.equal(player(pause, f.provider).revealedTraitors?.includes(f.identity) ?? false, false,
|
|
'a private replacement does not turn the historically declared card into current public ownership');
|
|
await restored(f, pause);
|
|
});
|
|
|
|
void test('SQLite lost final Collection response restores the original actual Mentat draw without replaying battle or phase effects', async t => {
|
|
const f = await fixture(t, { remote: true });
|
|
const due = await closeGate(f, f.initial, true);
|
|
let lost = false;
|
|
f.hooks.afterStatement = async sql => {
|
|
if (!sql.startsWith('UPDATE rooms SET state')) return;
|
|
const saved = await f.restart().readRoom(f.code);
|
|
if (saved.phase === 8 && !lost) { lost = true; throw new Error('lost final Collection response'); }
|
|
};
|
|
try {
|
|
await assert.rejects(mentat(f, due), /lost final Collection response/);
|
|
} finally { delete f.hooks.afterStatement; }
|
|
assert.equal(lost, true);
|
|
const pause = await f.restart().readRoom(f.code);
|
|
assert.equal(pause.phase, 8);
|
|
assert.equal(pause.turn, due.turn);
|
|
assert.equal(pause.battle, null);
|
|
assert.equal(pause.pendingNexusHarkonnenReplacement, null);
|
|
assert.equal(pause.nexusHarkonnenReplacementHistory!.at(-1)!.drawn, due.traitorReserve![0]);
|
|
assert.deepEqual(player(pause, f.provider).traitors, [...player(due, f.provider).traitors, due.traitorReserve![0]]);
|
|
assert.deepEqual(pause.traitorReserve, due.traitorReserve!.slice(1));
|
|
await restored(f, pause);
|
|
});
|
|
|
|
for (const counterKind of ['printed', 'BG Worthless'] as const) {
|
|
void test(`SQLite actual allied Harkonnen ${counterKind} counter preempts the Nexus acknowledgement and preserves its face`, async t => {
|
|
const f = await fixture(t, { remote: true, advanced: true, counterKind });
|
|
f.save(f.beforeCall);
|
|
const declared = await act(f, f.beforeCall, f.provider, f.callAction);
|
|
assert.ok(declared.response);
|
|
assert.equal(viewGame(declared, f.holder).nexusHarkonnenBetrayalReaction, null);
|
|
const counter = counterKind === 'printed'
|
|
? declared.players.find(player => player.id !== f.provider && player.hand.some(card => card.effect === 'karama'))
|
|
: declared.players.find(player => player.faction === 'beneGesserit');
|
|
assert.ok(counter);
|
|
const card = counter.hand.find(card => counterKind === 'printed' ? card.effect === 'karama' : card.kind === 'worthless');
|
|
assert.ok(card, 'the conserved actual native deal supplies the counter card');
|
|
const canceled = await act(f, declared, counter.id, { type: 'card', mode: 'cancel', card: card.id });
|
|
const done = await nativeCounters(f, canceled);
|
|
assert.equal(done.pendingNexusHarkonnenBetrayal ?? null, null);
|
|
assert.equal(done.pendingNexusHarkonnenReplacement ?? null, null);
|
|
assert.equal(viewGame(done, f.holder).nexusHarkonnenBetrayalReaction, null);
|
|
assert.deepEqual(done.nexusHarkonnenBetrayalHistory, f.beforeCall.nexusHarkonnenBetrayalHistory);
|
|
assert.deepEqual(done.nexusHarkonnenBetrayalCursor, f.beforeCall.nexusHarkonnenBetrayalCursor);
|
|
assert.deepEqual(done.nexusCards!.cards, f.beforeCall.nexusCards!.cards);
|
|
assert.deepEqual(player(done, f.provider).traitors, player(f.beforeCall, f.provider).traitors);
|
|
assert.equal(done.discard.filter(value => value.id === card.id).length, 1);
|
|
await restored(f, done);
|
|
});
|
|
}
|
|
|
|
void test('SQLite credential revocation at the final Use fence cannot commit a source, shuffle or replacement', async t => {
|
|
const f = await fixture(t);
|
|
const auth = await authenticate(f, f.holder);
|
|
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
|
|
let revoked = false;
|
|
f.hooks.beforeWrite = async () => {
|
|
assert.equal(revoked, false);
|
|
revoked = true;
|
|
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
|
|
f.protectedRows = records(f.sqlite, f.code);
|
|
};
|
|
try {
|
|
await assert.rejects(f.restart().act(f.code, auth, f.initial.version, { type: 'nexusHarkonnenBetrayalUse', event: f.event }, clock));
|
|
} finally { delete f.hooks.beforeWrite; }
|
|
assert.equal(revoked, true);
|
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
|
|
inventory(f, await f.restart().readRoom(f.code));
|
|
await assert.rejects(f.restart().readSeatView(f.code, auth));
|
|
});
|
|
|
|
void test('SQLite every credential keeps independent own autopilot control while the original Harkonnen source remains pending', async t => {
|
|
const f = await fixture(t, { receiverCount: 2 });
|
|
let game = f.initial;
|
|
for (const auth of f.auths) {
|
|
const previous = game;
|
|
game = await act(f, game, auth.playerId, { type: 'setAutopilot', difficulty: 'Easy' });
|
|
assert.equal(player(game, auth.playerId).autopilot, 'Easy');
|
|
assert.deepEqual(game.pendingNexusHarkonnenBetrayal, previous.pendingNexusHarkonnenBetrayal);
|
|
assert.deepEqual(game.nexusCards!.cards, previous.nexusCards!.cards);
|
|
assert.deepEqual(game.traitorReserve, previous.traitorReserve);
|
|
game = await act(f, game, auth.playerId, { type: 'setAutopilot', difficulty: null });
|
|
assert.equal(player(game, auth.playerId).autopilot ?? null, null);
|
|
assert.deepEqual(game.pendingNexusHarkonnenBetrayal, f.initial.pendingNexusHarkonnenBetrayal);
|
|
}
|
|
await restored(f, game);
|
|
});
|