Files
Dune/tests/prototype-room.test.ts
T
Quintine 178251ad35 Restore native Ecaz simulation continuation and current rule fixtures
Match original Ecaz sample snapshots to their native Occupy and Advanced assassination state. Preserve older absent-power samples and resume the original scenario instead of rejecting fresh saved output. Update stale fixture reactions and module expectations while retaining meaningful Banker guidance and positive legal commitments. Correct the unsupported rules-completion claim.

Validation: typecheck/lint, 173 affected tests across 16 files, bounded independent source review, and one direct Ecaz continuation completed through 178 accepted actions. Runtime fingerprint 95d009d3957650b08bb24826b70aa5f00a74cdd9992b6a6c222f0665d3e3759c; documentation-only proof update a91ab248109bee74a74373fb544027a2e7cc8f178e26d2c9dad475a9618eb64d. Full rules, comprehensive assurance and deployed acceptance remain open. No saved-room or provider changes.
2026-10-18 10:08:55 +11:00

1047 lines
59 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import {
startIxPrototypeRoom,
startPrototypeRoom,
} from '../tools/prototype-room';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { createLeaderSkills } from '../game/leader-skills';
import { createStrongholdCards } from '../game/stronghold-cards';
import { treacheryDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { richeseCards } from '../game/richese-cards';
function fixture(t: test.TestContext) {
const db = new DatabaseSync(':memory:');
t.after(() => db.close());
db.exec(
'CREATE TABLE rooms(code TEXT PRIMARY KEY, state TEXT, version INTEGER, updated_at INTEGER); CREATE TABLE seats(room_code TEXT, player_id TEXT, token_hash TEXT);',
);
const game = createGame(
'PROTOTYP',
newPlayer('i', 'Ix player', 'ixians'),
true,
);
game.expansions = ['ix'];
joinGame(game, newPlayer('t', 'Tleilaxu player', 'tleilaxu'));
game.players.forEach((player) => {
player.ready = true;
});
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(
game.code,
JSON.stringify(game),
7,
100,
);
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(
'KEEPME00',
'{"unchanged":true}',
3,
50,
);
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(
game.code,
'i',
'unchanged-private-session',
);
return { db, game };
}
void test('Ecaz Treachery preview saves the complete independent inventory before genuine setup once', (t) => {
const { db } = fixture(t);
const beforeSeats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const result = startPrototypeRoom(db, 'PROTOTYP', 7, 'ecaz-treachery');
const row = db.prepare('SELECT state FROM rooms WHERE code=?').get('PROTOTYP')!;
const g = JSON.parse(row.state as string) as Game;
assert.equal(result.version, 8);
assert.equal(g.ecazTreachery, true);
const cards = [...g.deck, ...g.players.flatMap((p) => p.hand), ...(g.ixSetupCards ?? [])];
assert.deepEqual(cards.filter((c) => c.id.startsWith('ecaz-')).map((c) => c.id).sort(),
['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements']);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), beforeSeats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
const rows = db.prepare('SELECT * FROM rooms').all();
assert.throws(() => startPrototypeRoom(db, 'PROTOTYP', 8, 'ecaz-treachery'), /cannot redeal/);
assert.deepEqual(db.prepare('SELECT * FROM rooms').all(), rows);
});
void test('local prototype starts the named ready lobby once, retaining every seat and other room', (t) => {
const { db } = fixture(t);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db
.prepare('SELECT * FROM rooms WHERE code = ?')
.get('KEEPME00');
const result = startIxPrototypeRoom(db, 'PROTOTYP', 7);
assert.equal(result.version, 8);
assert.equal(result.status, 'setup');
const saved = db
.prepare('SELECT state, version FROM rooms WHERE code = ?')
.get('PROTOTYP')!;
assert.equal(JSON.parse(saved.state as string).version, saved.version);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(
db.prepare('SELECT * FROM rooms WHERE code = ?').get('KEEPME00'),
other,
);
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 7), /changed/);
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 8), /fresh lobby/);
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
});
void test('unready, wrong identity and incompatible lobbies fail without writing any room or seat', (t) => {
const { db, game } = fixture(t);
for (const mutate of [
() => {
game.players[0].ready = false;
},
() => {
game.players[0].ready = true;
game.code = 'NOTMATCH';
},
() => {
game.code = 'PROTOTYP';
game.expansions = [];
},
]) {
mutate();
db.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(
JSON.stringify(game),
'PROTOTYP',
);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 7));
assert.deepEqual(
db.prepare('SELECT * FROM rooms ORDER BY code').all(),
before,
);
assert.equal(
db.prepare('SELECT count(*) AS count FROM seats').get()!.count,
1,
);
}
});
void test('Leader Skills prototype saves the real private setup once without changing other rooms or seats', (t) => {
const { db } = fixture(t);
const game = createGame(
'PROTOTYP',
newPlayer('i', 'Atreides', 'atreides'),
true,
);
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
game.players.forEach((p) => {
p.ready = true;
});
db.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(
JSON.stringify(game),
game.code,
);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db
.prepare('SELECT * FROM rooms WHERE code = ?')
.get('KEEPME00');
const result = startPrototypeRoom(db, game.code, 7, 'leader-skills');
assert.equal(result.version, 8);
const saved = JSON.parse(
db.prepare('SELECT state FROM rooms WHERE code = ?').get(game.code)!
.state as string,
);
assert.equal(saved.version, 8);
assert.equal(saved.setupStage, 'leaderSkills');
assert.equal(saved.leaderSkills.offers.i.cards.length, 2);
assert.equal(saved.leaderSkills.offers.h.cards.length, 2);
assert.equal(saved.players[0].hand.length, 1);
assert.equal(saved.players[1].hand.length, 2);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(
() => startPrototypeRoom(db, game.code, 8, 'leader-skills'),
/redeal/,
);
assert.deepEqual(
db.prepare('SELECT * FROM rooms ORDER BY code').all(),
rooms,
);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(
db.prepare('SELECT * FROM rooms WHERE code = ?').get('KEEPME00'),
other,
);
});
void test('Nexus prototype initializes all twelve hidden cards once and preserves unrelated saved state', (t) => {
const { db } = fixture(t);
const game = createGame(
'PROTOTYP',
newPlayer('i', 'Atreides', 'atreides'),
true,
);
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
game.players.forEach((player) => {
player.ready = true;
});
db.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(
JSON.stringify(game),
game.code,
);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db
.prepare('SELECT * FROM rooms WHERE code = ?')
.get('KEEPME00');
startPrototypeRoom(db, game.code, 7, 'nexus');
const saved = JSON.parse(
db.prepare('SELECT state FROM rooms WHERE code = ?').get(game.code)!
.state as string,
) as Game;
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.nexusCards!.cards!.deck.length, 12);
assert.equal(new Set(saved.nexusCards!.cards!.deck).size, 12);
for (const player of saved.players) {
const view = viewGame(saved, player.id);
assert.equal(view.nexusCards!.card, null);
assert.equal(view.nexusCards!.deckCount, 12);
assert.equal('deck' in view.nexusCards!, false);
}
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'nexus'), /changed/);
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'nexus'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(
db.prepare('SELECT * FROM rooms WHERE code = ?').get('KEEPME00'),
other,
);
});
void test('Moritani assassination preview starts only the fresh opted-in Advanced profile and preserves seats', (t) => {
const {db}=fixture(t);
const game=createGame('PROTOTYP',newPlayer('i','Moritani','moritani'),true,['ecaz']);
joinGame(game,newPlayer('g','Guild','guild'));for(const p of game.players)p.ready=true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game),game.code);
const seats=db.prepare('SELECT * FROM seats').all(),other=db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
startPrototypeRoom(db,game.code,7,'moritani-assassinate');
const saved=JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(saved.status,'setup');assert.equal(saved.version,8);assert.equal(saved.advanced,true);
assert.equal(saved.moritaniAssassinatePreview,true);assert.equal(saved.moritaniAssassinate!.owner,'i');
assert.deepEqual(saved.moritaniAssassinate!.opportunities,[]);assert.deepEqual(saved.moritaniAssassinateCallEvents,[]);
for(const p of saved.players)assert.equal('moritaniAssassinatePreview' in viewGame(saved,p.id),false);
const rows=db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(()=>startPrototypeRoom(db,game.code,7,'moritani-assassinate'));
assert.throws(()=>startPrototypeRoom(db,game.code,8,'moritani-assassinate'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(),rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(),seats);assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'),other);
});
void test('Semuta development profile begins only a fresh Richese lobby without changing saved seats', (t) => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const result = startPrototypeRoom(db, game.code, 7, 'semuta');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.status, 'setup');
assert.equal(saved.semutaPreview, true);
assert.ok(saved.richeseCache?.some(card => card.id === 'richese-semuta-drug'));
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'semuta'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'semuta'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
void test('Kull preview starts a fresh ready CHOAM lobby once and preserves other rooms and seats', (t) => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('c', 'CHOAM', 'choam'), false, ['choam', 'ix']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const result = startPrototypeRoom(db, game.code, 7, 'kull');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.status, 'setup');
assert.equal(saved.kullPreview, true);
const cards = [...saved.deck, ...saved.players.flatMap(p => p.hand), ...(saved.ixSetupCards ?? [])];
assert.equal(cards.filter(card => card.id === 'ix-kull-wahad').length, 1);
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'kull'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'kull'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
void test('Richese Betrayal profile initializes one fresh paired Nexus game and preserves seats and other saves', (t) => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const result = startPrototypeRoom(db, game.code, 7, 'richese-betrayal');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.status, 'setup');
assert.equal(saved.richeseBetrayalPreview, true);
assert.equal(saved.nexusCards!.cards!.deck.length, 12);
assert.equal(new Set(saved.nexusCards!.cards!.deck).size, 12);
assert.equal(saved.richeseCache?.length, 10);
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'richese-betrayal'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'richese-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
for (const advanced of [false, true]) {
void test(`Nexus Kull ${advanced ? 'Advanced' : 'Basic'} entry preserves canonical inventories and cannot redeal a saved game`, (t) => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('c', 'CHOAM', 'choam'), advanced, ['choam', 'ix']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
startPrototypeRoom(db, game.code, 7, 'nexus-kull');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(saved.status, 'setup');
assert.equal(saved.nexusKullPreview, true);
assert.equal(saved.kullPreview, true);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort((a,b) => a.localeCompare(b)),
[...NEXUS_FACTIONS].sort((a,b) => a.localeCompare(b)));
const cards = [...saved.deck, ...saved.players.flatMap(p => p.hand), ...(saved.ixSetupCards ?? [])];
assert.equal(cards.filter(card => card.id === 'ix-kull-wahad').length, 1);
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'nexus-kull'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'nexus-kull'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
for (const advanced of [false, true]) {
for (const homeworlds of [false, true]) {
void test(`Guild Betrayal ${advanced ? 'Advanced' : 'Basic'} ${homeworlds ? 'Homeworld' : 'base'} entry preserves explicit modules and rejects redealing without touching other saves`, (t) => {
const { db } = fixture(t);
let game = createGame('PROTOTYP', newPlayer('g', 'Guild', 'guild'), advanced);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
if (homeworlds)
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
db.prepare('UPDATE seats SET player_id=? WHERE room_code=?').run('g', game.code);
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(game.code, 'e', 'unchanged-emperor-session');
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const result = startPrototypeRoom(db, game.code, 7, 'guild-betrayal');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.guildBetrayalPreview, true);
assert.equal(!!saved.homeworlds, homeworlds);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort((a,b) => a.localeCompare(b)),
[...NEXUS_FACTIONS].sort((a,b) => a.localeCompare(b)));
assert.deepEqual(saved.expansions, []);
for (const p of game.players)
assert.equal(saved.players.find(seat => seat.id === p.id)!.name, p.name);
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'guild-betrayal'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'guild-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
}
for (const advanced of [false, true]) {
void test(`Ixian Nexus replacement ${advanced ? 'Advanced' : 'Basic'} starts only the exact fresh classic lobby without changing seats or native options`, t => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), advanced);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const lobbyRows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 6, 'ixian-replacement'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), lobbyRows);
const result = startPrototypeRoom(db, game.code, 7, 'ixian-replacement');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.code, game.code);
assert.equal(saved.host, game.host);
assert.equal(saved.advanced, advanced);
assert.deepEqual(saved.expansions, game.expansions);
assert.deepEqual(saved.playerPositions, game.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
game.players.map(p => [p.id, p.name, p.faction]));
assert.equal(saved.nexusIxianReplacementPreview, true);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
for (const p of saved.players) {
const view = viewGame(saved, p.id);
assert.equal(view.nexusIxianReplacementPreview, true);
assert.equal(view.nexusCards!.card, null);
assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
}
const startedRows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ixian-replacement'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), startedRows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
void test('Ixian Nexus replacement rejects unready, expansion and native Ixian lobbies without reconfiguring them', t => {
const { db } = fixture(t);
const classic = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), true);
joinGame(classic, newPlayer('e', 'Emperor', 'emperor'));
for (const p of classic.players) p.ready = true;
const unready = structuredClone(classic);
unready.players[0].ready = false;
const expansion = structuredClone(classic);
expansion.expansions = ['ix'];
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
joinGame(native, newPlayer('e', 'Emperor', 'emperor'));
for (const p of native.players) p.ready = true;
for (const game of [unready, expansion, native]) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const seats = db.prepare('SELECT * FROM seats').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'ixian-replacement'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
}
});
void test('Ixian replacement CLI takes a private exact-version backup before starting and cannot overwrite a started game or prior proof', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-ixian-replacement-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const game = createGame('PROTOTYP', newPlayer('a', 'Atreides', 'atreides'), true);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) p.ready = true;
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(game.code, JSON.stringify(game), 7, 100);
const other = structuredClone(game);
other.code = 'KEEPGAME';
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
startPrototypeRoom(db, other.code, 9, 'ixian-replacement');
for (const p of game.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(game.code, p.id, 'isolated-test-' + p.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const seats = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
db.close();
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'ixian-replacement', '--db', database, '--room', game.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const started = invoke(7, output);
assert.equal(started.status, 0, started.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
backup.close();
const current = new DatabaseSync(database, { readOnly: true });
t.after(() => current.close());
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
const savedRow = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(game.code)!;
assert.equal(savedRow.version, 8);
const saved = JSON.parse(savedRow.state as string) as Game;
assert.equal(saved.nexusIxianReplacementPreview, true);
assert.equal(saved.status, 'setup');
assert.equal(saved.advanced, game.advanced);
assert.deepEqual(saved.playerPositions, game.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id,p.name,p.faction]), game.players.map(p => [p.id,p.name,p.faction]));
assert.notEqual(invoke(8, output).status, 0, 'existing proof directory cannot be reused');
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0, 'stale backup version cannot be admitted');
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0, 'current version cannot redeal a started game');
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
});
for (const advanced of [false, true]) {
for (const seats of [2, 3, 4, 6]) {
void test(`Ixian Betrayal fresh ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry keeps original identity and undealt Nexus`, t => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), advanced, ['ix']);
const factions = ['atreides', 'emperor', 'guild', 'tleilaxu', 'fremen'] as const;
for (let index = 0; index < seats - 1; index++)
joinGame(game, newPlayer(`p${index}`, `Original ${index}`, factions[index]));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const credentials = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 6, 'ixian-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
const started = startPrototypeRoom(db, game.code, 7, 'ixian-betrayal');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(started.version, 8);
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.code, game.code);
assert.equal(saved.host, game.host);
assert.equal(saved.advanced, game.advanced);
assert.deepEqual(saved.expansions, game.expansions);
assert.deepEqual(saved.playerPositions, game.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), game.players.map(p => [p.id, p.name, p.faction]));
assert.equal(saved.nexusIxianBetrayalPreview, true);
const physical = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand), ...(saved.ixSetupCards ?? [])];
assert.equal(physical.length, 47);
assert.equal(new Set(physical.map(card => card.id)).size, 47);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual(saved.nexusCards!.cards!.discard, []);
for (const p of saved.players) {
assert.equal(saved.nexusCards!.cards!.hands[p.id], null);
const view = viewGame(saved, p.id);
assert.equal(view.nexusIxianBetrayalPreview, true);
assert.equal(view.nexusIxianBetrayalReaction, null);
assert.equal(view.nexusCards!.card, null);
assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
}
const after = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ixian-betrayal'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ix'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
}
void test('Ixian Betrayal rejects incompatible original lobbies without durable changes', t => {
const { db } = fixture(t);
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
joinGame(native, newPlayer('a', 'Atreides', 'atreides'));
for (const p of native.players) p.ready = true;
const alterations: ((g: Game) => void)[] = [
g => { g.players[0].ready = false; },
g => { g.code = 'NOTMATCH'; },
g => { g.players[0].faction = 'emperor'; },
g => { g.players[1].faction = 'ixians'; },
g => { g.players[1].faction = 'choam'; },
g => { g.players[1].faction = 'richese'; },
g => { g.players[1].faction = 'ecaz'; },
g => { g.players[1].faction = 'moritani'; },
g => { g.expansions = []; },
g => { g.expansions = ['ix', 'choam']; },
g => { g.expansions = ['ix', 'ecaz']; },
g => { g.discoveryEnabled = true; },
g => { g.ecazTreachery = true; },
g => { g.semutaPreview = true; },
g => { g.nexusIxianReplacementPreview = true; },
g => { g.nexusIxianBetrayalPreview = true; },
g => { g.kullPreview = true; },
g => { g.nexusKullPreview = true; },
g => { g.guildBetrayalPreview = true; },
g => { g.richeseBetrayalPreview = true; },
g => { g.moritaniAssassinatePreview = true; },
g => { g.leaderSkills = createLeaderSkills(() => 0.5); },
g => { g.players.pop(); },
g => { g.players.push(...Array.from({ length: 5 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
];
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
const game = applyAction(native, native.host, { type, enabled: true });
for (const p of game.players) p.ready = true;
return game;
});
const candidates = alterations.map(change => {
const game = structuredClone(native);
change(game);
return game;
}).concat(modules);
for (const game of candidates) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats').all();
const changes = db.prepare('SELECT total_changes() AS changes').get()!.changes;
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'ixian-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.equal(db.prepare('SELECT total_changes() AS changes').get()!.changes, changes);
}
});
void test('Ixian Betrayal CLI preserves a private consistent exact-version backup and never overwrites proof or resets a started room', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-ixian-betrayal-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), true, ['ix']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) p.ready = true;
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(game.code, JSON.stringify(game), 7, 100);
const other = createGame('KEEPGAME', newPlayer('a', 'Atreides', 'atreides'), true);
joinGame(other, newPlayer('e', 'Emperor', 'emperor'));
for (const p of other.players) p.ready = true;
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
startPrototypeRoom(db, other.code, 9, 'ixian-replacement');
for (const p of game.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(game.code, p.id, 'isolated-test-' + p.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
db.close();
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'ixian-betrayal', '--db', database, '--room', game.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const started = invoke(7, output);
assert.equal(started.status, 0, started.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
backup.close();
const current = new DatabaseSync(database, { readOnly: true });
t.after(() => current.close());
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
const savedRow = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(game.code)!;
assert.equal(savedRow.version, 8);
const saved = JSON.parse(savedRow.state as string) as Game;
assert.equal(saved.nexusIxianBetrayalPreview, true);
assert.equal(saved.status, 'setup');
assert.equal(saved.advanced, game.advanced);
assert.deepEqual(saved.expansions, game.expansions);
assert.deepEqual(saved.playerPositions, game.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), game.players.map(p => [p.id, p.name, p.faction]));
assert.notEqual(invoke(8, output).status, 0);
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
});
function harkonnenLobby(advanced = true, seats = 3) {
const game = createGame('PROTOTYP', newPlayer('h', 'Native Harkonnen', 'harkonnen'), advanced);
const factions = ['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit'] as const;
for (let index = 0; index < seats - 1; index++)
joinGame(game, newPlayer(`p${index}`, `Original ${index}`, factions[index]));
for (const player of game.players) player.ready = true;
return game;
}
for (const advanced of [false, true]) {
for (const seats of [2, 3, 4, 5, 6]) {
void test(`Harkonnen Betrayal ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry preserves seats and leaves all Nexus physically undealt`, t => {
const { db } = fixture(t);
const lobby = harkonnenLobby(advanced, seats);
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(lobby), lobby.code);
const credentials = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'harkonnen-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
const result = startPrototypeRoom(db, lobby.code, 7, 'harkonnen-betrayal');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(lobby.code)!.state as string) as Game;
assert.equal(result.version, 8);
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.nexusHarkonnenBetrayalPreview, true);
assert.equal(saved.advanced, advanced);
assert.equal(saved.host, lobby.host);
assert.deepEqual(saved.expansions, []);
assert.deepEqual(saved.playerPositions, lobby.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
const stock = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand)];
assert.equal(stock.length, 33);
assert.equal(new Set(stock.map(card => card.id)).size, 33);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual(saved.nexusCards!.cards!.discard, []);
for (const player of saved.players) {
assert.equal(saved.nexusCards!.cards!.hands[player.id], null);
const view = viewGame(saved, player.id);
assert.equal(view.nexusHarkonnenBetrayalReaction, null);
assert.equal(view.nexusCards!.card, null);
assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
}
const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'harkonnen-betrayal'));
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'nexus'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
}
void test('Harkonnen Betrayal rejects incompatible lobbies, overlays and ordinary public starts without changing durable records', t => {
const { db } = fixture(t);
const native = harkonnenLobby();
const changes: ((game: Game) => void)[] = [
game => { game.players[0].ready = false; },
game => { game.code = 'NOTMATCH'; },
game => { game.players[0].faction = 'fremen'; },
game => { game.players[1].faction = 'harkonnen'; },
...(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const).map(faction =>
(game: Game) => { game.players[1].faction = faction; }),
...(['ix', 'choam', 'ecaz'] as const).map(expansion =>
(game: Game) => { game.expansions = [expansion]; }),
game => { game.discoveryEnabled = true; },
game => { game.ecazTreachery = true; },
game => { game.semutaPreview = true; },
game => { game.nexusIxianReplacementPreview = true; },
game => { game.nexusIxianBetrayalPreview = true; },
game => { game.nexusHarkonnenBetrayalPreview = true; },
game => { game.kullPreview = true; },
game => { game.nexusKullPreview = true; },
game => { game.guildBetrayalPreview = true; },
game => { game.richeseBetrayalPreview = true; },
game => { game.moritaniAssassinatePreview = true; },
game => { game.leaderSkills = createLeaderSkills(() => 0.5); },
game => { game.players.splice(1); },
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
];
// Original Tech3+/Advanced Strongholds2+/Homeworlds are now supported by
// the shared classic Nexus core, so they are not rejection cases.
for (const game of changes.map(change => {
const game = structuredClone(native);
change(game);
return game;
})) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const seats = db.prepare('SELECT * FROM seats').all();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'harkonnen-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
}
const original = structuredClone(native);
for (const action of [{ type: 'start', profile: 'harkonnen-betrayal' }, { type: 'initializeHarkonnenNexusBetrayalGameForAudit' }])
assert.throws(() => applyAction(native, native.host, action));
assert.deepEqual(native, original);
});
void test('Harkonnen Betrayal CLI backs up every room and seat privately before the exact fresh-version start and cannot redeal or overwrite proof', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-harkonnen-betrayal-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const lobby = harkonnenLobby();
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100);
const other = harkonnenLobby(false, 2);
other.code = 'KEEPGAME';
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
startPrototypeRoom(db, other.code, 9, 'harkonnen-betrayal');
for (const player of lobby.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, player.id, 'isolated-test-' + player.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const seats = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
db.close();
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'harkonnen-betrayal', '--db', database, '--room', lobby.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const result = invoke(7, output);
assert.equal(result.status, 0, result.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
backup.close();
const current = new DatabaseSync(database, { readOnly: true });
t.after(() => current.close());
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
const row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
const saved = JSON.parse(row.state as string) as Game;
assert.equal(row.version, 8);
assert.equal(saved.nexusHarkonnenBetrayalPreview, true);
assert.equal(saved.status, 'setup');
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
assert.notEqual(invoke(8, output).status, 0);
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
});
void test('Leader Skills rejects invalid or already-dealt lobbies and forged public activation without changing saved rooms or credentials', t => {
const { db } = fixture(t);
const native = harkonnenLobby();
const changes: ((game: Game) => void)[] = [
game => { game.players[0].ready = false; },
game => { game.code = 'NOTMATCH'; },
game => { game.players[1].faction = game.players[0].faction; },
game => { game.leaderSkills = createLeaderSkills(() => 0.5); },
game => { game.players.splice(1); },
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
];
for (const game of changes.map(change => {
const game = structuredClone(native);
change(game);
return game;
})) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats').all();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'leader-skills'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
}
const original = structuredClone(native);
for (const action of [{ type: 'start', profile: 'banker-income' }, { type: 'initializeSpiceBankerIncomeGameForAudit' },
{ type: 'spiceBankerIncome', enabled: true }])
assert.throws(() => applyAction(native, native.host, action));
assert.deepEqual(native, original);
});
void test('Leader Skills CLI requires a fresh exact-version private backup and preserves native setup, old rooms and credentials', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-leader-skills-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const lobby = harkonnenLobby(true, 6);
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100);
const other = harkonnenLobby(false, 2);
other.code = 'KEEPGAME';
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
startPrototypeRoom(db, other.code, 9, 'leader-skills');
for (const player of lobby.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, player.id, 'isolated-test-' + player.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
db.close();
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'leader-skills', '--db', database, '--room', lobby.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const result = invoke(7, output);
assert.equal(result.status, 0, result.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
backup.close();
const current = new DatabaseSync(database, { readOnly: true });
t.after(() => current.close());
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
const row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
const saved = JSON.parse(row.state as string) as Game;
assert.equal(row.version, 8);
assert.equal(saved.spiceBankerIncomePreview, true);
assert.equal(saved.status, 'setup');
assert.equal(saved.setupStage, 'prediction');
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
assert.deepEqual(saved.players.map(p => p.hand), lobby.players.map(p => p.hand));
assert.deepEqual(saved.leaderSkills!.offers, {});
assert.notEqual(invoke(8, output).status, 0);
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
});
function strongholdFactionsLobby(
count = 3,
roster: readonly FactionId[] = ['ixians', 'choam', 'atreides', 'harkonnen', 'emperor', 'guild'],
expansions = ['ix', 'choam'],
) {
const game = createGame('PROTOTYP', newPlayer('i', roster[0], roster[0]), true, expansions);
for (let index = 1; index < count; index++)
joinGame(game, newPlayer('seat-' + index, roster[index], roster[index]));
game.players.forEach(player => { player.ready = true; });
return game;
}
void test('Stronghold factions admits only fresh exact-version native Advanced lobbies without changing seats or other rooms', t => {
const { db } = fixture(t);
const families: { roster: FactionId[]; expansions: string[]; minimum?: number }[] = [
{ roster: ['ixians', 'choam', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['ix', 'choam'] },
{ roster: ['ixians', 'tleilaxu', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['ix'] },
{ roster: ['choam', 'richese', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['choam'] },
{ roster: ['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'guild'], expansions: ['ix', 'choam'], minimum: 4 },
...(['ixians', 'tleilaxu', 'choam', 'richese'] as const).map(faction => ({
roster: [faction, 'guild', 'atreides', 'harkonnen', 'emperor', 'fremen'] as FactionId[],
expansions: [faction === 'ixians' || faction === 'tleilaxu' ? 'ix' : 'choam'],
})),
];
for (const { family, count } of families.flatMap(family =>
[2, 3, 4, 5, 6].filter(count => count >= (family.minimum ?? 2)).map(count => ({ family, count })))) {
const lobby = strongholdFactionsLobby(count, family.roster, family.expansions);
const original = structuredClone(lobby);
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(lobby), lobby.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
startPrototypeRoom(db, lobby.code, 7, 'stronghold-factions');
const row = db.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
const saved = JSON.parse(row.state as string) as Game;
assert.equal(saved.version, 8);
assert.equal(row.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.advanced, true);
assert.deepEqual(saved.expansions, family.expansions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
original.players.map(p => [p.id, p.name, p.faction]));
assert.deepEqual(saved.playerPositions, original.playerPositions);
const cards = [...saved.deck, ...saved.discard, ...(saved.ixSetupCards ?? []), ...saved.players.flatMap(p => p.hand)];
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(family.expansions).map(card => card.id).sort());
assert.equal(new Set(cards.map(card => card.id)).size, family.expansions.includes('ix') ? 47 : 35);
const cache = family.roster.includes('richese') ? richeseCards().map(card => card.id).sort() : [];
assert.deepEqual((saved.richeseCache ?? []).map(card => card.id).sort(), cache);
assert.deepEqual(saved.strongholdCards, createStrongholdCards());
const ix = saved.players.find(p => p.faction === 'ixians');
if (ix) assert.equal((ix.elites?.reserves ?? 0) + (ix.elites?.tanks ?? 0) +
Object.values(ix.elites?.forces ?? {}).reduce((sum, amount) => sum + amount, 0), 7);
const choam = saved.players.find(p => p.faction === 'choam');
if (choam) assert.equal(choam.leaders.filter(leader => leader.id === 'choam-auditor').length, 1);
for (const p of saved.players) {
const view = viewGame(saved, p.id);
assert.deepEqual(view.strongholdCards, saved.strongholdCards);
assert.equal('deck' in view, false);
for (const otherPlayer of view.players)
if (otherPlayer.id !== p.id) assert.equal(otherPlayer.hand, undefined);
}
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started);
}
});
void test('Stronghold faction admission rejects incompatible modules, history, ownership and public retrofit immutably', t => {
const { db } = fixture(t);
const native = strongholdFactionsLobby();
const variants: ((game: Game) => void)[] = [
game => { game.advanced = false; },
game => { game.expansions = ['ix']; },
game => { game.expansions = ['ix', 'choam', 'ecaz']; },
game => { game.expansions = ['choam']; },
game => { game.expansions = ['ix', 'choam', 'ix']; },
game => { game.expansions = []; },
game => { game.players[2].faction = 'ecaz'; },
game => { game.players[0].faction = 'guild'; game.players[1].faction = 'fremen'; },
game => { game.players[0].ready = false; },
game => { game.turn = 2; },
game => { game.players[0].battleLosses = 1; },
game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.owners.arrakeen = game.host; },
game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.claimedTurn = 1; },
game => { game.nexusCards = { cards: null, phase: null }; },
game => { game.leaderSkills = createLeaderSkills(() => 0); },
game => { game.homeworlds = { custody: null }; },
];
for (const mutate of variants) {
const game = structuredClone(native);
mutate(game);
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(game), native.code);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const seats = db.prepare('SELECT * FROM seats').all();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
}
const original = structuredClone(native);
for (const action of [{ type: 'start', profile: 'stronghold-factions' },
{ type: 'initializeStrongholdFactionsGameForAudit' }, { type: 'strongholdFactions', enabled: true }])
assert.throws(() => applyAction(native, native.host, action));
assert.deepEqual(native, original);
});
void test('Stronghold factions CLI privately backs up the exact original native lobby and cannot reset or overwrite proof', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-stronghold-factions-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const lobby = strongholdFactionsLobby(6);
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100);
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run('KEEPGAME', JSON.stringify(harkonnenLobby(false, 2)), 9, 50);
for (const p of lobby.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, p.id, 'private-native-session-' + p.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
db.close();
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'stronghold-factions', '--db', database, '--room', lobby.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const result = invoke(7, output);
assert.equal(result.status, 0, result.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
backup.close();
const current = new DatabaseSync(database, { readOnly: true });
t.after(() => current.close());
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPGAME'), before.find(row => row.code === 'KEEPGAME'));
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
const row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
const saved = JSON.parse(row.state as string) as Game;
assert.equal(row.version, 8);
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
lobby.players.map(p => [p.id, p.name, p.faction]));
assert.deepEqual(saved.playerPositions, lobby.playerPositions);
assert.deepEqual(saved.strongholdCards, createStrongholdCards());
assert.deepEqual([...saved.deck, ...(saved.ixSetupCards ?? []), ...saved.players.flatMap(p => p.hand)].map(card => card.id).sort(),
treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
assert.notEqual(invoke(8, output).status, 0);
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
});