Files
Dune/tests/prototype-room.test.ts
T
Quintine 288870c842 Integrate expanded native Stronghold faction rules
Broaden existing Advanced Stronghold profile to selected E1/E2 native pairs/mixed families and classic opponents;35/47 ordinary decks with separate Richese10cache. Native No-Field/real Stone acquisition/battles, FaceDancer originalwinner/replacement/endMentat and legalAI reuse existinghandlers. Targeted16, actual11 rule cases,18unstaged games11066actions/no rejects/290JSON restores; corrected per-family reporter exercised on3two-seat families. Human390pxG7VDY6KBv12 Stone kill/Habbanya/retain and736HJUD3v8 two-reserve FaceDance. Finalquick26/build/guide205links. No new save/privacy/custody/HTTP/full6500 assurance campaign, pending rulings/public/deployed gates unchanged.
2026-10-02 11:05:54 +10:00

1127 lines
64 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, native Ixian and Homeworld 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;
const homeworld = applyAction(classic, classic.host, { type: 'homeworlds', enabled: true });
for (const p of homeworld.players) p.ready = true;
for (const game of [unready, expansion, native, homeworld]) {
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 and public start 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);
}
const original = structuredClone(native);
for (const action of [{ type: 'start' }, { type: 'start', advancedPreview: true }])
assert.throws(() => applyAction(native, native.host, action));
assert.deepEqual(native, original);
});
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'))); },
];
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
const game = applyAction(native, native.host, { type, enabled: true });
for (const player of game.players) player.ready = true;
return game;
});
for (const game of [...changes.map(change => {
const game = structuredClone(native);
change(game);
return game;
}), ...modules]) {
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);
});
for (const advanced of [false, true]) {
for (const seats of [2, 3, 4, 5, 6]) {
void test(`Banker income ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry conserves the original base and skill inventories without changing credentials`, 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, 'banker-income'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
startPrototypeRoom(db, lobby.code, 7, 'banker-income');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(lobby.code)!.state as string) as Game;
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.spiceBankerIncomePreview, true);
assert.equal(saved.advanced, advanced);
assert.equal(saved.host, lobby.host);
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.ok(saved.leaderSkills);
const skills = [...saved.leaderSkills.deck, ...Object.values(saved.leaderSkills.offers).flatMap(offer => offer.cards),
...saved.leaderSkills.assignments.map(assignment => assignment.skill)];
assert.deepEqual(skills.sort(), createLeaderSkills(() => 0.5).deck.sort());
for (const player of saved.players) {
const view = viewGame(saved, player.id);
assert.deepEqual(view.spiceBankerIncome, { deferred: [], usedThisPhase: [] });
assert.equal(view.spiceBankerIncomePreview, true);
}
const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
for (const profile of ['banker-income', 'leader-skills', 'nexus'] as const)
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, profile));
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('Banker income rejects incompatible profiles and public activation without modifying any saved room or credential', 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; },
...(['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.nexusCards = { cards: null, phase: null }; },
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.spiceBankerIncomePreview = 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'))); },
];
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
const game = applyAction(native, native.host, { type, enabled: true });
for (const player of game.players) player.ready = true;
return game;
});
for (const game of [...changes.map(change => {
const game = structuredClone(native);
change(game);
return game;
}), ...modules]) {
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, 'banker-income'));
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('Banker income CLI requires a fresh exact-version private backup and preserves native setup, old rooms and credentials', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-banker-income-'));
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', 'banker-income', '--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); },
];
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);
}
for (const type of ['homeworlds', 'techTokens']) {
const game = applyAction(native, native.host, { type, enabled: true });
game.players.forEach(player => { player.ready = true; });
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();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
}
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);
});