Implement private Tupile intelligence and Homeworld occupation evidence

This commit is contained in:
Quintine committed 2026-09-10 09:08:52 +10:00
1 parent 1db11a0187
commit 61689514a0
43 files changed
+3420 -188

No files matched your search

+10 -12
View File
@@ -32,14 +32,17 @@ function hold(g: Game, id: string, match: (card: Card) => boolean) {
/** Emperor-only cases use the genuine audited setup pipeline. CHOAM's complete
* expansion deck remains gated, so its shared-window cases use an explicit
* faction seam with real base-deck IDs and conserved Homeworld counters. */
function fixture(choam = false, advanced = true) {
function fixture(choam = false, advanced = true, richese = false) {
let g = createGame(
'BIDDINGEND',
newPlayer('e', 'Emperor', 'emperor'),
advanced,
choam ? ['choam'] : [],
choam || richese ? ['choam'] : [],
);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
if (richese) joinGame(g, newPlayer('r', 'Richese', 'richese'));
// The Richese audit uses its separate cache plus the implemented base deck.
if (richese) g.expansions = [];
if (choam) {
joinGame(g, newPlayer('c', 'CHOAM', 'choam'));
Object.assign(g, {
@@ -440,16 +443,11 @@ void test('ordinary CHOAM market retains its existing end-of-Bidding decision wh
});
void test('a scheduled post-normal Richese cache auction completes before the shared Kaitain window opens', () => {
let g = fixture(false, false);
// Add the explicitly staged Richese faction and complete physical cache to
// the audited core game; the full Richese expansion setup remains gated.
const r = newPlayer('r', 'Richese', 'richese');
r.spice = 20;
g.players.push(r);
g.order = ['e', 'a', 'r'];
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
g.richeseCache = richeseCards();
g.richeseRemoved = [];
let g = fixture(false, false, true);
// Richese and its separate physical cache originate in genuine setup before
// occupation-history initialization. Public expansion release remains gated.
assert.equal(g.richeseCache?.length, 10);
assert.deepEqual(g.richeseRemoved, []);
const disposal = hold(g, 'e', (card) => card.kind === 'worthless');
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'richeseDeclaration');
+6 -4
View File
@@ -116,6 +116,10 @@ async function fixture(ecaz = false) {
tokens.map((token) => store.rooms.authenticate(code, token)),
);
let g = await store.rooms.readRoom(code);
// Offline lobby faction seam: public expansion joins remain gated, but final
// identities precede every genuine setup allocation and signed history.
g.players[2] = engine.newPlayer(g.players[2].id, 'CHOAM', 'choam');
if (ecaz) g.players[3] = engine.newPlayer(g.players[3].id, 'Ecaz', 'ecaz');
g = engine.applyAction(g, g.host, { type: 'homeworlds', enabled: true });
for (const player of g.players)
g = engine.applyAction(g, player.id, { type: 'ready' });
@@ -137,13 +141,11 @@ async function fixture(ecaz = false) {
assert.equal(g.status, 'playing');
for (const player of g.players) g.deck.push(...player.hand.splice(0));
const ids = g.players.map((p) => p.id);
// Explicit expansion-faction seams after genuine base Homeworld setup.
// Retain every physical card/counter; no full CHOAM/Ecaz setup is certified.
g.players[2].faction = 'choam';
// The final faction roster is established before genuine Homeworld setup.
// These later positions preserve its signed identities and physical counters.
g.players[2].name = 'CHOAM';
if (ecaz) {
Object.assign(g.players[3], {
faction: 'ecaz',
name: 'Ecaz',
reserves: 20,
forces: {},
+21 -30
View File
@@ -1,4 +1,3 @@
import { newPlayer } from '../game/engine';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import {
caladanVictoryFixture,
@@ -7,39 +6,31 @@ import {
victoryPlayer as p,
} from './fixture-caladan-victory';
/** Real native setup and battle dispatch, with an explicit Ecaz/Harkonnen/BG
* scenario seam to exercise entry effects without fabricating victory receipts. */
export function caladanAmbassadorFixture() {
const g = caladanVictoryFixture({ advanced: true });
for (const [id, faction] of [
['ec', 'ecaz'],
['h', 'harkonnen'],
['bg', 'beneGesserit'],
] as const) {
const player = newPlayer(id, faction, faction);
Object.assign(player, {
reserves: 20,
tanks: 0,
forces: {},
hand: [],
traitors: [],
traitorChoices: [],
spice: 20,
});
g.players.push(player);
}
/** Real final native/Ecaz/Harkonnen/BG setup and battle dispatch, with
* staged positions and Ambassador tokens to exercise entry effects without fabricating victory receipts. */
export function caladanAmbassadorFixture(seatIds: Record<string, string> = {}) {
const id = (key: string) => seatIds[key] ?? key;
const g = caladanVictoryFixture({
advanced: true,
seatIds,
extraSeats: [
{ id: 'ec', faction: 'ecaz' },
{ id: 'h', faction: 'harkonnen' },
{ id: 'bg', faction: 'beneGesserit' },
],
});
g.expansions.push('ecaz');
g.order = g.players.map((player) => player.id);
for (const player of [p(g, 'a'), p(g, 'g')]) {
for (const player of [p(g, id('a')), p(g, id('g'))]) {
player.forces['arrakeen:10'] = player.forces['hagga_basin:12'];
delete player.forces['hagga_basin:12'];
}
p(g, 'ec').ally = 'h';
p(g, 'h').ally = 'ec';
p(g, 'ec').forces = { 'red_chasm:7': 1 };
p(g, 'ec').reserves = 19;
p(g, 'bg').forces = { 'sietch_tabr:14': 1 };
p(g, 'bg').reserves = 19;
p(g, id('ec')).ally = id('h');
p(g, id('h')).ally = id('ec');
p(g, id('ec')).forces = { 'red_chasm:7': 1 };
p(g, id('ec')).reserves = 19;
p(g, id('bg')).forces = { 'sietch_tabr:14': 1 };
p(g, id('bg')).reserves = 19;
const state = createAmbassadors(() => 0);
const guild = state.tokens.find((token) => token.effect === 'guild')!;
const fremen = state.tokens.find((token) => token.effect === 'fremen')!;
@@ -78,7 +69,7 @@ export function caladanAmbassadorFixture() {
allowed: true,
},
}).state;
holdVictoryCard(g, 'ec', 'karama');
holdVictoryCard(g, id('ec'), 'karama');
inventory(g);
return g;
}
+24 -9
View File
@@ -10,6 +10,7 @@ import {
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import type { FactionId } from '../game/catalog';
import { baseDeck, ixDeck } from '../game/cards';
import {
homeworldContext,
@@ -24,17 +25,27 @@ export const victoryReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
/** Genuine setup and physical cards; only the later phase and force positions
* are staged. Optional Tleilaxu uses its actual Ix setup, not a faction swap. */
export function caladanVictoryFixture(
options: { advanced?: boolean; tleilaxu?: boolean; native?: number } = {},
options: {
advanced?: boolean;
tleilaxu?: boolean;
native?: number;
seatIds?: Record<string, string>;
extraSeats?: readonly { id: string; faction: FactionId }[];
} = {},
): Game {
const id = (key: string) => options.seatIds?.[key] ?? key;
let g = createGame(
'CALADANVICTORY',
newPlayer('a', 'Atreides', 'atreides'),
newPlayer(id('a'), 'Atreides', 'atreides'),
options.advanced ?? false,
options.tleilaxu ? ['ix'] : [],
);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
if (options.tleilaxu) joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g = applyAction(g, 'a', { type: 'homeworlds', enabled: true });
joinGame(g, newPlayer(id('g'), 'Guild', 'guild'));
if (options.tleilaxu) joinGame(g, newPlayer(id('t'), 'Tleilaxu', 'tleilaxu'));
// Audit-only final roster is fixed before setup signs public history.
for (const extra of options.extraSeats ?? [])
g.players.push(newPlayer(id(extra.id), extra.faction, extra.faction));
g = applyAction(g, id('a'), { type: 'homeworlds', enabled: true });
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
@@ -64,13 +75,17 @@ export function caladanVictoryFixture(
phaseOpening: null,
response: null,
decision: null,
active: 'a',
active: id('a'),
ready: [],
order: g.players.map((player) => player.id),
});
positionVictoryArmy(g, 'a', options.native ?? 6, 3);
positionVictoryArmy(g, 'g', 17, 3);
if (options.tleilaxu) positionVictoryArmy(g, 't', 20, 0);
positionVictoryArmy(g, id('a'), options.native ?? 6, 3);
positionVictoryArmy(g, id('g'), 17, 3);
if (options.tleilaxu) positionVictoryArmy(g, id('t'), 20, 0);
for (const extra of options.extraSeats ?? []) {
const player = victoryPlayer(g, id(extra.id));
Object.assign(player, { reserves: 20, tanks: 0, forces: {} });
}
victoryInventory(g);
return g;
}
+14 -9
View File
@@ -22,17 +22,22 @@ export const grummanReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
* pre-Collection board position is staged; no signed phase/arrival is fabricated.
* Runtime Moritani support here does not lift its public expansion-start gate. */
export function grummanCollectionFixture(
options: { native?: number; advanced?: boolean } = {},
options: {
native?: number;
advanced?: boolean;
seatIds?: [string, string, string];
} = {},
): Game {
const [m, a, h] = options.seatIds ?? ['m', 'a', 'g'];
let g = createGame(
'GRUMMANCOLLECTION',
newPlayer('m', 'Moritani', 'moritani'),
newPlayer(m, 'Moritani', 'moritani'),
options.advanced ?? false,
[],
);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('g', 'Harkonnen', 'harkonnen'));
g = applyAction(g, 'm', { type: 'homeworlds', enabled: true });
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
g = applyAction(g, m, { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let n = 0; g.status === 'setup' && n < 60; n++) {
@@ -61,7 +66,7 @@ export function grummanCollectionFixture(
moved: 0,
});
}
const owner = grummanPlayer(g, 'm');
const owner = grummanPlayer(g, m);
owner.reserves = options.native ?? 8;
owner.forces = { 'polar_sink:0': 20 - owner.reserves };
assert.ok(g.moritaniTerror);
@@ -72,9 +77,9 @@ export function grummanCollectionFixture(
Object.assign(g, {
phase: 5,
turn: 2,
active: 'a',
movementRemaining: ['a'],
order: ['a', 'm', 'g'],
active: a,
movementRemaining: [a],
order: [a, m, h],
ready: [],
phaseOpening: null,
response: null,
+22 -28
View File
@@ -1,4 +1,3 @@
import { newPlayer } from '../game/engine';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import {
homeworldRevivalFixture,
@@ -8,35 +7,30 @@ import {
revivalInventory as inventory,
} from './fixture-homeworld-revival';
/** Genuine native revival fixture plus an explicit Ecaz/BG faction seam: no
/** Genuine final native/Ecaz/BG revival roster with staged positions: no
* fabricated revival, Ambassador, response or continuation receipt. */
export function homeworldRevivalAmbassadorFixture() {
const g = homeworldRevivalFixture({ advanced: true, tleilaxu: true });
for (const [id, faction] of [
['ec', 'ecaz'],
['bg', 'beneGesserit'],
['a', 'atreides'],
] as const) {
const player = newPlayer(id, faction, faction);
Object.assign(player, {
reserves: 20,
forces: {},
tanks: 0,
spice: 20,
hand: [],
traitors: [],
traitorChoices: [],
});
g.players.push(player);
}
export function homeworldRevivalAmbassadorFixture(
seatIds: Record<string, string> = {},
) {
const id = (key: string) => seatIds[key] ?? key;
const g = homeworldRevivalFixture({
advanced: true,
tleilaxu: true,
seatIds,
extraSeats: [
{ id: 'ec', faction: 'ecaz' },
{ id: 'bg', faction: 'beneGesserit' },
{ id: 'a', faction: 'atreides' },
],
});
g.expansions.push('ecaz');
g.order = g.players.map((player) => player.id);
p(g, 'ec').ally = 'a';
p(g, 'a').ally = 'ec';
p(g, 'bg').forces = { 'sietch_tabr:14': 1 };
p(g, 'bg').reserves = 19;
p(g, 'ec').forces = { 'red_chasm:7': 1 };
p(g, 'ec').reserves = 19;
p(g, id('ec')).ally = id('a');
p(g, id('a')).ally = id('ec');
p(g, id('bg')).forces = { 'sietch_tabr:14': 1 };
p(g, id('bg')).reserves = 19;
p(g, id('ec')).forces = { 'red_chasm:7': 1 };
p(g, id('ec')).reserves = 19;
const state = createAmbassadors(() => 0);
const first = state.tokens.find((token) => token.effect === 'guild')!;
const second = state.tokens.find((token) => token.effect === 'fremen')!;
@@ -77,6 +71,6 @@ export function homeworldRevivalAmbassadorFixture() {
}).state;
inventory(g);
const entered = enterHomeworldRevival(g);
holdRevivalCard(entered, 'ec', 'karama');
holdRevivalCard(entered, id('ec'), 'karama');
return entered;
}
+18 -6
View File
@@ -10,6 +10,7 @@ import {
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import type { FactionId } from '../game/catalog';
import { baseDeck, ixDeck } from '../game/cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
@@ -24,17 +25,23 @@ export function homeworldRevivalFixture(
options: {
advanced?: boolean;
tleilaxu?: boolean;
seatIds?: Record<string, string>;
extraSeats?: readonly { id: string; faction: FactionId }[];
} = {},
): Game {
const id = (key: string) => options.seatIds?.[key] ?? key;
let g = createGame(
'HOMEREVIVAL',
newPlayer('f', 'Fremen', 'fremen'),
newPlayer(id('f'), 'Fremen', 'fremen'),
options.advanced ?? false,
options.tleilaxu ? ['ix'] : [],
);
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
if (options.tleilaxu) joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g = applyAction(g, 'f', { type: 'homeworlds', enabled: true });
joinGame(g, newPlayer(id('e'), 'Emperor', 'emperor'));
if (options.tleilaxu) joinGame(g, newPlayer(id('t'), 'Tleilaxu', 'tleilaxu'));
// Audit-only final roster is fixed before setup signs public history.
for (const extra of options.extraSeats ?? [])
g.players.push(newPlayer(id(extra.id), extra.faction, extra.faction));
g = applyAction(g, id('f'), { type: 'homeworlds', enabled: true });
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
@@ -68,8 +75,13 @@ export function homeworldRevivalFixture(
response: null,
decision: null,
});
positionRevivalForces(g, 'f', { native: 3, tanks: 5, eliteTanks: 2 });
if (options.tleilaxu) positionRevivalForces(g, 't', { native: 9, tanks: 5 });
positionRevivalForces(g, id('f'), { native: 3, tanks: 5, eliteTanks: 2 });
if (options.tleilaxu)
positionRevivalForces(g, id('t'), { native: 9, tanks: 5 });
for (const extra of options.extraSeats ?? []) {
const player = revivalPlayer(g, id(extra.id));
Object.assign(player, { reserves: 20, tanks: 0, forces: {} });
}
revivalInventory(g);
return g;
}
+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import { botActions } from '../game/bots';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { quoteHomeworldCustody } from '../game/homeworld-custody';
export const tupilePlayer = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
export const tupileReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
/** Real faction setup creates both signed ledgers using the final authenticated
* seat IDs. Only conserved armies and a settled movement opportunity are staged;
* the default contact then comes from an actual paid Homeworld shipment. */
export function tupileIntelligenceFixture(options: {
seatIds?: [string, string, string]; contact?: boolean; advanced?: boolean;
} = {}): Game {
const [c, a, h] = options.seatIds ?? ['c', 'a', 'h'];
let g = createGame('TUPILEINTELLIGENCE', newPlayer(c, 'CHOAM', 'choam'), options.advanced ?? false, []);
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
g = applyAction(g, c, { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 60; step++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = applyAction(g, p.id, action); break; }
}
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
g = next;
}
assert.equal(g.status, 'playing');
assert.equal(g.homeworlds?.historyVersion, 1);
assert.equal(g.tupileIntelligence?.owner, c);
assert.deepEqual(g.homeworldOccupationHistory?.qualifications, []);
for (const p of g.players) {
g.deck.push(...p.hand.splice(0));
Object.assign(p, { spice: 20, forces: {}, reserves: 20, tanks: 0, shipped: false, moved: 0 });
}
Object.assign(tupilePlayer(g, c), { reserves: 10, forces: { 'polar_sink:0': 10 } });
Object.assign(g, {
phase: 5, turn: 2, active: c, order: [c, a, h], movementRemaining: [c, a, h],
ready: [], phaseOpening: null, response: null, decision: null, storm: 18, spice: {},
});
if (options.contact !== false) g = enterTupileContact(g);
tupileInventory(g);
return g;
}
export function enterTupileContact(g: Game): Game {
const owner = g.players.find((p) => p.faction === 'choam')!;
return applyAction(g, owner.id, {
type: 'homeworldShip', event: viewGame(g, owner.id).homeworldShipment!.event,
destination: 'homeworld:atreides', sources: { 'homeworld:choam': { normal: 1, elite: 0 } },
});
}
/** Conserved leave/reentry scenarios exercise current contact without forging
* occupation or intelligence receipts. Both worlds retain native garrisons. */
export function positionTupileContact(g: Game, present: boolean): void {
const owner = g.players.find((p) => p.faction === 'choam')!;
const one = { normal: 1, elite: 0 }, zero = { normal: 0, elite: 0 };
const quote = quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [
{ homeworld: 'homeworld:choam', player: owner.id, withdraw: present ? one : zero, deposit: present ? zero : one },
{ homeworld: 'homeworld:atreides', player: owner.id, withdraw: present ? zero : one, deposit: present ? one : zero },
]);
g.homeworlds!.custody = quote.state;
for (const p of quote.players) tupilePlayer(g, p.id).reserves = p.reserves;
tupileInventory(g);
}
export function holdTupileCard(g: Game, id: string, kind: string): Card {
const index = g.deck.findIndex((card) => card.id === kind || card.kind === kind || card.effect === kind);
assert.ok(index >= 0, `Missing canonical card ${kind}`);
const card = g.deck.splice(index, 1)[0];
tupilePlayer(g, id).hand.push(card);
return card;
}
export function tupileReject(g: Game, id: string, action: Action, match: RegExp = /./): void {
const before = tupileReload(g);
assert.throws(() => applyAction(g, id, action), match);
assert.deepEqual(g, before);
}
export function tupileInventory(g: Game): void {
homeworldGameIntegrity(g);
for (const p of g.players) {
const visitors = Object.values(g.homeworlds!.custody!.visitors)
.reduce((sum, world) => sum + (world[p.id]?.normal ?? 0) + (world[p.id]?.elite ?? 0), 0);
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0) + visitors, 20);
}
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)].map((c) => c.id).sort(), baseDeck().map((c) => c.id).sort());
}
+2 -4
View File
@@ -109,10 +109,8 @@ async function persisted() {
}
const auths = await Promise.all(credentials.map((value) => store.rooms.authenticate(code, value)));
const initial = await store.rooms.readRoom(code);
let encoded = JSON.stringify(grummanCollectionFixture());
for (const [index, id] of ['m', 'a', 'g'].entries())
encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(auths[index].playerId));
const g: Game = JSON.parse(encoded);
// Setup signs the final authenticated identities, including occupation history.
const g = grummanCollectionFixture({ seatIds: auths.map((auth) => auth.playerId) as [string, string, string] });
g.code = code;
g.host = auths[0].playerId;
g.version = initial.version;
+11
View File
@@ -1,3 +1,5 @@
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
import { homeworldContext } from '../game/homeworld-game';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -70,6 +72,13 @@ function setup(faction: 'emperor' | 'fremen', homeworlds = true) {
});
return g;
}
/** Record the explicit conserved fixture position before checking read/reload purity. */
function recordPosition(g: Game) {
if (g.homeworldOccupationHistory)
g.homeworldOccupationHistory = observeHomeworldOccupation(g.homeworldOccupationHistory,
homeworldContext(g), g.homeworlds!.custody!, g.turn, 'change',
`fixture-position-${g.homeworldOccupationHistory.sources.length}`);
}
function inventory(g: Game) {
for (const p of g.players) {
assert.equal(p.reserves + p.tanks + sum(p.forces), 20);
@@ -106,6 +115,7 @@ function battle(faction: 'emperor' | 'fremen', homeworlds = true) {
player(g).elites!.forces['arrakeen:10'] = 1;
}
Object.assign(player(g, 'q'), { reserves: 19, forces: { 'arrakeen:10': 1 } });
recordPosition(g);
inventory(g);
g = applyAction(g, 'p', {
type: 'chooseBattle',
@@ -155,6 +165,7 @@ function tanks(faction: 'emperor' | 'fremen', homeworlds = true) {
p.elites.reserves -= 3;
p.elites.tanks = 3;
}
recordPosition(g);
inventory(g);
return g;
}
+121
View File
@@ -297,6 +297,105 @@ void test('two foreign armies at an empty native home resolve automatically afte
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
});
void test('ordered loser elimination records a sole foreign qualifier before the Basic winner loses its entire dialed army', () => {
let g = setup(['atreides', 'guild', 'harkonnen']);
assert.equal(g.homeworlds!.historyVersion, 1);
assert.ok(g.homeworldOccupationHistory);
const world = 'homeworld:harkonnen';
invade(g, 'atreides', world, 3);
invade(g, 'guild', world, 3);
seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves;
seat(g, 'harkonnen').reserves = 0;
g = prepared(g, 'atreides', 'guild', world);
assert.equal(
g.homeworldOccupationHistory!.qualifications.filter(
(fact) => fact.world === world,
).length,
0,
);
g = commit(g, 'atreides', 3);
g = commit(reload(g), 'guild');
assert.equal(g.lastBattleContext!.winner, 'atreides');
assert.equal(seat(g, 'atreides').tanks, 3);
assert.equal(seat(g, 'guild').tanks, 3);
assert.equal(g.homeworlds!.custody!.visitors[world], undefined);
const history = g.homeworldOccupationHistory!;
const qualifications = history.qualifications.filter(
(fact) => fact.world === world,
);
assert.equal(qualifications.length, 1);
assert.equal(qualifications[0].player, 'atreides');
assert.equal(qualifications[0].cause, 'sole');
const source = history.sources.find(
(entry) => entry.event === qualifications[0].event,
)!;
const snapshot = JSON.parse(history.snapshots[source.snapshot]) as [
string,
[string, number, number][],
][];
const armies = snapshot.find(([location]) => location === world)![1];
assert.deepEqual(
armies.filter(([, normal, elite]) => normal + elite > 0),
[['atreides', 3, 0]],
'The original qualification observes the winner before its own dialed losses.',
);
inventory(g);
const saved = reload(g);
for (let attempt = 0; attempt < 3; attempt++)
g = normalizeAutomaticGame(reload(g));
assert.deepEqual(reload(g), saved);
assert.deepEqual(
g.homeworldOccupationHistory,
saved.homeworldOccupationHistory,
);
});
void test('simultaneous Lasgun Shield destruction of two invaders at an empty native Homeworld never invents a sole qualifier', () => {
let g = setup(['atreides', 'guild', 'harkonnen']);
const world = 'homeworld:harkonnen';
invade(g, 'atreides', world, 3);
invade(g, 'guild', world, 3);
seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves;
seat(g, 'harkonnen').reserves = 0;
const laser = hold(g, 'atreides', 'lasgun');
const shield = hold(g, 'guild', 'shield');
g = prepared(g, 'atreides', 'guild', world);
assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, []);
assert.equal(
g.homeworldOccupationHistory!.qualifications.filter(
(fact) => fact.world === world,
).length,
0,
);
g = commit(g, 'atreides', 0, { weapon: laser });
g = commit(reload(g), 'guild', 0, { defense: shield });
assert.equal(g.lastBattleContext!.winner, null);
assert.equal(g.battle, null);
assert.equal(seat(g, 'atreides').tanks, 3);
assert.equal(seat(g, 'guild').tanks, 3);
assert.equal(g.homeworlds!.custody!.visitors[world], undefined);
assert.equal(
g.homeworldOccupationHistory!.qualifications.filter(
(fact) => fact.world === world,
).length,
0,
);
for (const raw of g.homeworldOccupationHistory!.snapshots) {
const snapshot = JSON.parse(raw) as [string, [string, number, number][]][];
const present = snapshot
.find(([location]) => location === world)![1]
.filter(([, normal, elite]) => normal + elite > 0);
assert.ok(
present.length !== 1 || present[0][0] === 'harkonnen',
'No sequential half-explosion snapshot may show an invented sole invader.',
);
}
for (const id of [laser, shield])
assert.equal(g.discard.filter((card) => card.id === id).length, 1);
inventory(g);
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
});
void test('real Lasgun Shield explosion preserves mixed native reserves and offers the printed-strength typed loss choice after mandatory discard', () => {
let g = setup(['atreides', 'emperor']);
invade(g, 'atreides', 'homeworld:emperor', 3);
@@ -355,6 +454,28 @@ void test('real Lasgun Shield explosion preserves mixed native reserves and offe
for (const card of [laser, shield])
assert.equal(done.discard.filter((held) => held.id === card).length, 1);
inventory(done);
const history = done.homeworldOccupationHistory!;
assert.ok(history);
assert.equal(
history.qualifications.filter(
(fact) => fact.world === 'homeworld:emperor',
).length,
0,
);
const source = history.sources.at(-1)!;
const snapshot = JSON.parse(history.snapshots[source.snapshot]) as [
string,
[string, number, number][],
][];
const native = snapshot
.find(([world]) => world === 'homeworld:emperor')![1]
.find(([owner]) => owner === 'emperor')!;
assert.equal(native[1] + native[2], originalNative - 2);
assert.equal(native[2], seat(done, 'emperor').elites!.reserves);
assert.deepEqual(
reload(normalizeAutomaticGame(reload(done))),
reload(done),
);
reject(done, 'emperor', action);
}
});
+4 -3
View File
@@ -112,6 +112,9 @@ async function fixture(revival = false, advanced = !revival) {
tokens.map((token) => store.rooms.authenticate(code, token)),
);
let g = await store.rooms.readRoom(code);
// Public expansion joins stay gated; establish the offline payer faction in
// the untouched lobby before genuine setup creates its signed history.
if (!revival) g.players[2] = engine.newPlayer(g.players[2].id, 'CHOAM', 'choam');
g = engine.applyAction(g, g.host, { type: 'homeworlds', enabled: true });
for (const player of g.players)
g = engine.applyAction(g, player.id, { type: 'ready' });
@@ -183,9 +186,7 @@ function position(g: engine.Game, index: number, reserves: number, tanks = 0) {
function charity(f: Fixture) {
let g = f.initial;
position(g, 1, 10);
// Explicit payer seam after genuine base setup; this is not CHOAM setup or
// deck certification. All original physical forces and cards are retained.
g.players[2].faction = 'choam';
// CHOAM's identity was fixed before setup; only its current balance is staged.
g.players[2].spice = 12;
g.players[1].spice = 9;
g.choamCharity = { turn: g.turn, canceled: false };
+8 -7
View File
@@ -22,10 +22,13 @@ function setup(advanced = true, ix = false) {
'HOMECHARITYENGINE',
newPlayer('a', 'Atreides', 'atreides'),
advanced,
ix ? ['ix'] : [],
ix ? ['ix', 'choam'] : ['choam'],
);
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('c', 'Guild', 'guild'));
joinGame(g, newPlayer('c', 'CHOAM', 'choam'));
// Audit CHOAM's faction with only the implemented physical base/Ix deck.
// The lobby roster is final before any setup pieces or history are created.
g.expansions = ix ? ['ix'] : [];
g = applyAction(g, 'a', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
@@ -64,12 +67,10 @@ function low(g: Game, id: string, reserves: number) {
homeworldGameIntegrity(g);
}
function choam(g: Game) {
// Explicit unsupported-faction component seam AFTER genuine base/ix setup.
// No CHOAM deck/setup completeness is claimed; preserve all original counters
// and card custody, change only the payer faction needed by this contract.
// Faction identity and both histories originate in genuine setup. Stage only
// the phase's existing payer balance/settlement, preserving physical custody.
const p = seat(g, 'c');
p.faction = 'choam';
p.name = 'CHOAM';
assert.equal(p.faction, 'choam');
p.spice = 12;
g.choamCharity = { turn: g.turn, canceled: false };
homeworldGameIntegrity(g);
+5 -5
View File
@@ -12,7 +12,7 @@ import {
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck, leaders } from '../game/cards';
import { baseDeck } from '../game/cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { createTechTokens } from '../game/tech-tokens';
@@ -36,6 +36,8 @@ function fixture(options: Options = {}) {
);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
// Select the audit-only final faction before real setup signs its roster.
if (options.shared) g.players[1] = newPlayer('g', 'Ecaz', 'ecaz');
g = applyAction(g, 'h', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
@@ -75,11 +77,9 @@ function fixture(options: Options = {}) {
hark.forces['imperial_basin:10'] =
20 - hark.reserves - Object.values(hark.forces).reduce((a, b) => a + b, 0);
if (options.shared) {
// Explicit Ecaz faction seam after genuine base Homeworld setup; the shared
// collection and all negotiated allocations still use production actions.
// The final Ecaz roster passed through genuine Homeworld setup; only
// the conserved collection positions and alliance are staged here.
const ecaz = own(g, 'g');
ecaz.faction = 'ecaz';
ecaz.leaders = leaders('ecaz');
ecaz.forces = {
'wind_pass:14': 2,
...(options.multiple ? { 'hagga_basin:12': 2 } : {}),
+20 -25
View File
@@ -89,7 +89,7 @@ function unitStore(runBots = bots.runBots) {
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
}
import { baseDeck, leaders } from '../game/cards';
import { baseDeck } from '../game/cards';
import { createTechTokens } from '../game/tech-tokens';
const {
applyAction,
@@ -112,17 +112,22 @@ type Options = {
stronghold?: boolean;
empty?: boolean;
technology?: boolean;
seatIds?: [string, string, string];
};
function fixture(options: Options = {}) {
const [h, partner, a] = options.seatIds ?? ['h', 'g', 'a'];
let g = createGame(
'GIEDICOLLECTION',
newPlayer('h', 'Harkonnen', 'harkonnen'),
newPlayer(h, 'Harkonnen', 'harkonnen'),
options.advanced ?? true,
[],
);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
g = applyAction(g, 'h', { type: 'homeworlds', enabled: true });
joinGame(g, newPlayer(partner, 'Guild', 'guild'));
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
// Only the offline lobby faction is selected here; no initialized history is
// altered and public expansion joins retain their existing gates.
if (options.shared) g.players[1] = newPlayer(partner, 'Ecaz', 'ecaz');
g = applyAction(g, h, { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let n = 0; g.status === 'setup' && n < 40; n++) {
@@ -151,7 +156,7 @@ function fixture(options: Options = {}) {
moved: 0,
});
}
const hark = own(g, 'h');
const hark = own(g, h);
hark.forces = {
'wind_pass:14': 2,
...(options.multiple ? { 'hagga_basin:12': 2 } : {}),
@@ -161,11 +166,9 @@ function fixture(options: Options = {}) {
hark.forces['imperial_basin:10'] =
20 - hark.reserves - Object.values(hark.forces).reduce((a, b) => a + b, 0);
if (options.shared) {
// Explicit Ecaz faction seam after genuine base Homeworld setup; the shared
// collection and all negotiated allocations still use production actions.
const ecaz = own(g, 'g');
ecaz.faction = 'ecaz';
ecaz.leaders = leaders('ecaz');
// Ecaz was seated before setup. The later shared position and allocations
// preserve that original signed faction identity.
const ecaz = own(g, partner);
ecaz.forces = {
'wind_pass:14': 2,
...(options.multiple ? { 'hagga_basin:12': 2 } : {}),
@@ -179,9 +182,9 @@ function fixture(options: Options = {}) {
Object.assign(g, {
phase: 5,
turn: 2,
active: 'a',
movementRemaining: ['a'],
order: ['g', 'h', 'a'],
active: a,
movementRemaining: [a],
order: [partner, h, a],
ready: [],
phaseOpening: null,
response: null,
@@ -196,7 +199,7 @@ function fixture(options: Options = {}) {
});
if (options.technology) {
g.techTokens = createTechTokens(g.players);
g.techTokens.heighliners = { owner: 'h', spice: 2 };
g.techTokens.heighliners = { owner: h, spice: 2 };
}
inventory(g);
return g;
@@ -239,16 +242,8 @@ async function persisted(options: Options = {}, collection = false) {
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const initial = await store.rooms.readRoom(code);
const prepared = fixture(options);
// Preserve the production room credentials while relocating the audited
// fixture's three identities consistently through its JSON keys and values.
let encoded = JSON.stringify(prepared);
for (const [index, id] of ['h', 'g', 'a'].entries())
encoded = encoded.replaceAll(
JSON.stringify(id),
JSON.stringify(auths[index].playerId),
);
let g: Game = JSON.parse(encoded);
// Bind session identities before genuine setup produces signed history.
let g = fixture({ ...options, seatIds: auths.map((auth) => auth.playerId) as [string, string, string] });
g.code = code;
g.host = auths[0].playerId;
g.version = initial.version;
+450
View File
@@ -0,0 +1,450 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHomeworldCustody,
type HomeworldCustodyContext,
} from '../game/homeworld-custody';
import {
createHomeworldOccupationHistory,
observeHomeworldOccupation,
validateHomeworldOccupationHistory,
tupileOccupationStatus,
type HomeworldOccupationHistory,
} from '../game/homeworld-occupation-history';
function fixture(advanced = false) {
const context: HomeworldCustodyContext = {
advanced,
players: [
{ id: 'c', faction: 'choam', reserves: 10, eliteReserves: 0 },
{ id: 'a', faction: 'atreides', reserves: 10, eliteReserves: 0 },
{ id: 'h', faction: 'harkonnen', reserves: 10, eliteReserves: 0 },
{ id: 'e', faction: 'emperor', reserves: 10, eliteReserves: 5 },
],
};
const custody = createHomeworldCustody(context);
const history = createHomeworldOccupationHistory(
context,
custody,
1,
'real-setup',
);
return { context, custody, history };
}
const reload = (
history: HomeworldOccupationHistory,
): HomeworldOccupationHistory => JSON.parse(JSON.stringify(history));
function unchangedThrow<T>(state: T, work: () => unknown) {
const before = structuredClone(state);
assert.throws(work, /occupation|Homeworld|forces|custody|reserves/i);
assert.deepEqual(state, before);
}
void test('only proven visitor-free setup establishes unoccupied Tupile; legacy unknown is never upgraded from current armies', () => {
const { context, custody, history } = fixture();
const before = structuredClone({ context, custody, history });
assert.equal(tupileOccupationStatus(undefined, context, 1), 'unknown');
assert.equal(tupileOccupationStatus(history, context, 1), 'unoccupied');
assert.deepEqual(history.qualifications, []);
let current = history;
for (let n = 0; n < 30; n++)
current = observeHomeworldOccupation(
current,
context,
custody,
1,
'change',
`idle-wrapper-${n}`,
);
assert.deepEqual(current, history);
assert.notEqual(current, history);
current.sources[0].event = 'mutated-return-value';
assert.deepEqual({ context, custody, history }, before);
custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } };
unchangedThrow(custody, () =>
createHomeworldOccupationHistory(context, custody, 1, 'forged-setup'),
);
custody.visitors['homeworld:choam'] = {};
unchangedThrow(custody, () =>
createHomeworldOccupationHistory(
context,
custody,
1,
'empty-visitor-record',
),
);
unchangedThrow(history, () =>
tupileOccupationStatus(
null as unknown as HomeworldOccupationHistory,
context,
1,
),
);
});
void test('mere invasion alongside natives does not qualify, but turn boundaries record every foreign faction without choosing a controller', () => {
const { context, custody, history } = fixture();
custody.visitors['homeworld:choam'] = {
a: { normal: 2, elite: 0 },
h: { normal: 1, elite: 0 },
};
const invaded = observeHomeworldOccupation(
history,
context,
custody,
1,
'change',
'shared-arrival',
);
assert.deepEqual(invaded.qualifications, []);
assert.equal(tupileOccupationStatus(invaded, context, 1), 'unoccupied');
const end = observeHomeworldOccupation(
invaded,
context,
custody,
1,
'turnEnd',
'turn-1-end',
);
assert.deepEqual(
end.qualifications.map(({ world, player, turn, cause }) => ({
world,
player,
turn,
cause,
})),
[
{ world: 'homeworld:choam', player: 'a', turn: 1, cause: 'turnEnd' },
{ world: 'homeworld:choam', player: 'h', turn: 1, cause: 'turnEnd' },
],
);
const start = observeHomeworldOccupation(
end,
context,
custody,
2,
'turnStart',
'turn-2-start',
);
assert.equal(start.qualifications.length, 4);
assert.deepEqual(
start.qualifications
.slice(2)
.map((fact) => [fact.player, fact.turn, fact.cause]),
[
['a', 2, 'turnStart'],
['h', 2, 'turnStart'],
],
);
assert.equal(tupileOccupationStatus(start, context, 2), 'unknown');
assert.equal(
start.snapshots.length,
2,
'unchanged boundary physical snapshot is shared',
);
assert.equal(start.sources.length, 4);
assert.equal(Object.hasOwn(start, 'controller'), false);
assert.equal(Object.hasOwn(start, 'income'), false);
validateHomeworldOccupationHistory(reload(start), context, 2);
});
void test('separately committed loser elimination records the transient sole foreign winner even when winner casualties immediately empty Tupile', () => {
const { context, custody, history } = fixture();
custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } };
const invaded = observeHomeworldOccupation(
history,
context,
custody,
1,
'change',
'invasion',
);
const native = context.players.find((p) => p.id === 'c')!;
native.reserves = 0;
const winnerAlone = observeHomeworldOccupation(
invaded,
context,
custody,
1,
'change',
'battle-loser-removed',
);
assert.deepEqual(winnerAlone.qualifications, [
{
event: 'battle-loser-removed',
world: 'homeworld:choam',
player: 'a',
faction: 'atreides',
turn: 1,
cause: 'sole',
},
]);
delete custody.visitors['homeworld:choam'];
const winnerLost = observeHomeworldOccupation(
reload(winnerAlone),
context,
custody,
1,
'change',
'battle-winner-casualties',
);
assert.deepEqual(winnerLost.qualifications, winnerAlone.qualifications);
assert.equal(tupileOccupationStatus(winnerLost, context, 1), 'unknown');
native.reserves = 10;
const restoredNative = observeHomeworldOccupation(
winnerLost,
context,
custody,
1,
'change',
'native-revival',
);
const nextTurn = observeHomeworldOccupation(
restoredNative,
context,
custody,
2,
'turnStart',
'next-turn',
);
assert.equal(
tupileOccupationStatus(nextTurn, context, 2),
'unknown',
'no inferred occupation expiry',
);
assert.equal(nextTurn.qualifications.length, 1);
validateHomeworldOccupationHistory(nextTurn, context, 2);
});
void test('one simultaneous casualty group creates no intermediate qualifier; typed armies at Kaitain and Salusa are separate worlds', () => {
const { context, custody, history } = fixture(true);
custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } };
const invaded = observeHomeworldOccupation(
history,
context,
custody,
1,
'change',
'arrive',
);
context.players.find((p) => p.id === 'c')!.reserves = 0;
delete custody.visitors['homeworld:choam'];
const destroyed = observeHomeworldOccupation(
invaded,
context,
custody,
1,
'change',
'simultaneous-explosion',
);
assert.deepEqual(destroyed.qualifications, []);
assert.equal(tupileOccupationStatus(destroyed, context, 1), 'unoccupied');
context.players.find((p) => p.id === 'e')!.reserves = 5;
custody.visitors['homeworld:emperor'] = { a: { normal: 1, elite: 0 } };
custody.visitors['homeworld:emperor:salusa'] = { h: { normal: 2, elite: 0 } };
const kaitain = observeHomeworldOccupation(
destroyed,
context,
custody,
1,
'change',
'separate-imperial-worlds',
);
assert.deepEqual(
kaitain.qualifications.map((q) => [q.world, q.player, q.cause]),
[['homeworld:emperor', 'a', 'sole']],
);
const boundary = observeHomeworldOccupation(
kaitain,
context,
custody,
1,
'turnEnd',
'imperial-end',
);
assert.deepEqual(
boundary.qualifications.slice(1).map((q) => [q.world, q.player, q.cause]),
[
['homeworld:emperor', 'a', 'turnEnd'],
['homeworld:emperor:salusa', 'h', 'turnEnd'],
],
);
assert.equal(
tupileOccupationStatus(boundary, context, 1),
'unoccupied',
'qualification elsewhere cannot remove CHOAM low advantage',
);
validateHomeworldOccupationHistory(boundary, context, 1);
});
void test('source events bind original turn, cause and exact public physical counts before replay or unchanged-snapshot suppression', () => {
const { context, custody, history } = fixture();
context.players.find((p) => p.id === 'c')!.reserves = 0;
custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } };
const first = observeHomeworldOccupation(
history,
context,
custody,
1,
'change',
'sole-entry',
);
const replay = observeHomeworldOccupation(
reload(first),
context,
custody,
1,
'change',
'sole-entry',
);
assert.deepEqual(replay, first);
assert.notEqual(replay, first);
unchangedThrow(first, () =>
observeHomeworldOccupation(
first,
context,
custody,
1,
'turnEnd',
'sole-entry',
),
);
unchangedThrow(first, () =>
observeHomeworldOccupation(
first,
context,
custody,
2,
'change',
'sole-entry',
),
);
custody.visitors['homeworld:choam'].a.normal = 2;
const second = observeHomeworldOccupation(
first,
context,
custody,
1,
'change',
'more-forces',
);
assert.equal(
second.qualifications.length,
1,
'same world/player/turn/cause retains first qualification evidence',
);
unchangedThrow(second, () =>
observeHomeworldOccupation(
second,
context,
custody,
1,
'change',
'sole-entry',
),
);
const unchanged = observeHomeworldOccupation(
second,
context,
custody,
1,
'change',
'new-wrapper-event',
);
assert.deepEqual(unchanged, second);
const end = observeHomeworldOccupation(
second,
context,
custody,
2,
'turnEnd',
'later-boundary',
);
unchangedThrow(end, () =>
observeHomeworldOccupation(end, context, custody, 1, 'change', 'backwards'),
);
unchangedThrow(end, () =>
validateHomeworldOccupationHistory(end, context, 1),
);
});
void test('saved qualifications, roster, event receipts and interned source snapshots reject corruption independently without mutation', () => {
const { context, custody, history } = fixture();
context.players.find((p) => p.id === 'c')!.reserves = 0;
custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } };
const initial = observeHomeworldOccupation(
history,
context,
custody,
1,
'change',
'qualified',
);
const corruptions = [
(h: HomeworldOccupationHistory) => {
h.qualifications = [];
},
(h: HomeworldOccupationHistory) => {
h.qualifications[0].player = 'h';
},
(h: HomeworldOccupationHistory) => {
h.qualifications[0].world = 'homeworld:atreides';
},
(h: HomeworldOccupationHistory) => {
h.sources[1].turn = 0;
},
(h: HomeworldOccupationHistory) => {
h.sources[1].cause = 'setup';
},
(h: HomeworldOccupationHistory) => {
h.sources[1].snapshot = 0;
},
(h: HomeworldOccupationHistory) => {
h.sources[1].signature = '';
},
(h: HomeworldOccupationHistory) => {
h.sources.push({ ...h.sources[1] });
},
(h: HomeworldOccupationHistory) => {
h.snapshots.push(h.snapshots[0]);
},
(h: HomeworldOccupationHistory) => {
h.snapshots[1] = '[]';
},
(h: HomeworldOccupationHistory) => {
h.seats[0][0] = 'unseated';
},
(h: HomeworldOccupationHistory) => {
h.signature = '';
},
(h: HomeworldOccupationHistory) => {
Object.assign(h, { controller: 'a' });
},
];
for (const corrupt of corruptions) {
const changed = reload(initial);
corrupt(changed);
unchangedThrow(changed, () =>
validateHomeworldOccupationHistory(changed, context, 1),
);
unchangedThrow(changed, () =>
observeHomeworldOccupation(
changed,
context,
custody,
1,
'change',
'must-not-adopt',
),
);
unchangedThrow(changed, () => tupileOccupationStatus(changed, context, 1));
}
const swapped = structuredClone(context);
swapped.players.find((p) => p.id === 'a')!.faction = 'guild';
unchangedThrow(initial, () =>
validateHomeworldOccupationHistory(initial, swapped, 1),
);
const reordered = { ...context, players: [...context.players].reverse() };
validateHomeworldOccupationHistory(initial, reordered, 1);
assert.equal(tupileOccupationStatus(initial, reordered, 1), 'unknown');
});
+9 -13
View File
@@ -11,14 +11,14 @@ import {
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck, leaders } from '../game/cards';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
const own = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture() {
function fixture(third: 'atreides' | 'richese' | 'ecaz' = 'atreides') {
let g = createGame(
'PAYMENTINCOME',
newPlayer('e', 'Emperor', 'emperor'),
@@ -27,6 +27,8 @@ function fixture() {
);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
// Final audit roster is fixed before any setup or occupation history exists.
if (third !== 'atreides') g.players[2] = newPlayer('a', third, third);
g = applyAction(g, 'e', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
@@ -372,13 +374,9 @@ void test('native Guild shipment crosses from five to low before collecting its
void test('Richese special cache purchase keeps its three-spice cost while low Kaitain income allows or cancels after reload', () => {
for (const cancel of [false, true]) {
let g = fixture();
// Genuine final Richese roster also initializes its physical cache.
let g = fixture('richese');
population(g, 'e', 4);
// Explicit Richese/cache seam after genuine base Homeworld setup; no full expansion setup claim.
own(g, 'a').faction = 'richese';
own(g, 'a').leaders = leaders('richese');
g.richeseCache = richeseCards();
g.richeseRemoved = [];
const payment = hold(g, 'a', 'karama');
const cancellation = hold(g, 'g', 'karama');
g = applyAction(g, 'a', {
@@ -401,12 +399,10 @@ void test('Richese special cache purchase keeps its three-spice cost while low K
void test('real Richese Ambassador purchase applies low Kaitain income after reload and rejects corrupted owner or gross before mutation', () => {
for (const cancel of [false, true]) {
let g = movement(fixture(), 'g');
let g = movement(fixture('ecaz'), 'g');
population(g, 'e', 4);
// Explicit Ecaz faction/token seam after genuine Homeworld setup; arrival,
// token trigger, paid card draw and income use their ordinary action paths.
own(g, 'a').faction = 'ecaz';
own(g, 'a').leaders = leaders('ecaz');
// Genuine final Ecaz roster; only this known Ambassador layout is staged.
// Arrival, trigger, paid draw and income still use production actions.
const karama = hold(g, 'a', 'karama');
let ambassadors = createAmbassadors(() => 0.2);
const token = ambassadors.tokens.find((t) => t.effect === 'richese')!;
@@ -110,13 +110,8 @@ async function persisted() {
}
const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
const initial = await store.rooms.readRoom(code);
const ids: Record<string, string> = {};
let encoded = JSON.stringify(homeworldRevivalAmbassadorFixture());
for (const [index, [id]] of roster.entries()) {
ids[id] = auths[index].playerId;
encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(ids[id]));
}
const g: Game = JSON.parse(encoded);
const ids = Object.fromEntries(roster.map(([id],index) => [id,auths[index].playerId]));
const g: Game = homeworldRevivalAmbassadorFixture(ids);
g.code = code;
g.host = ids.f;
g.version = initial.version;
@@ -22,11 +22,11 @@ const p = (g: Game, id: string) =>
g.players.find((player) => player.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function setup(roster: FactionId[] = ['fremen', 'emperor']) {
function setup(roster: FactionId[] = ['fremen', 'emperor'], advanced = false) {
let g = createGame(
'HOMEREVIVALBENEFITS',
newPlayer(roster[0], roster[0], roster[0]),
false,
advanced,
roster.some((id) => id === 'tleilaxu' || id === 'ixians') ? ['ix'] : [],
);
for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id));
@@ -359,12 +359,11 @@ void test('actual high Tleilax phase opening permits one ordinary free-income pa
});
void test('every AI respects the projected low-Homeworld special-Karama boundary and can allow the saved revival', () => {
let g = setup(['fremen', 'tleilaxu', 'atreides']);
let g = setup(['fremen', 'tleilaxu', 'atreides'], true);
position(g, 'fremen', 2, 12);
position(g, 'tleilaxu', 12, 0);
// This interaction uses the same real physical components under Advanced
// timing; public Advanced/Homeworld starts remain independently gated.
g.advanced = true;
// Advanced identity is established before genuine setup history is signed;
// public Advanced/Homeworld starts remain independently gated.
g = enterRevival(g);
const card = hold(g, 'tleilaxu', 'karama');
g = applyAction(g, 'fremen', { type: 'revive', amount: 1, elite: 0 });
@@ -113,11 +113,8 @@ async function persisted(mode: Mode = 'fremen') {
const tokens = [made.token, emperor.token!, ...(tleilaxu ? [tleilaxu.token!] : [])];
const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
const initial = await store.rooms.readRoom(code);
const prepared = homeworldRevivalFixture({ advanced, tleilaxu: expansion });
let encoded = JSON.stringify(prepared);
for (const [index, id] of ['f', 'e', ...(expansion ? ['t'] : [])].entries())
encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(auths[index].playerId));
let g: Game = JSON.parse(encoded);
const seatIds = Object.fromEntries(['f','e',...(expansion ? ['t'] : [])].map((id,index) => [id,auths[index].playerId]));
let g: Game = homeworldRevivalFixture({advanced,tleilaxu:expansion,seatIds});
g.code = code;
g.host = auths[0].playerId;
g.version = initial.version;
@@ -209,7 +209,10 @@ void test('advisor group size rejects malformed requests before cards, reserves
void test('absent Homeworld module preserves ordinary one-force accompaniment and rejects two', () => {
const g = setup(11).g;
// This parity fixture deliberately removes the whole optional module,
// including its setup evidence; it does not represent a legal live action.
g.homeworlds = null;
delete g.homeworldOccupationHistory;
const offered = ship(g);
reject(offered, 'beneGesserit', {
type: 'decision',
@@ -94,7 +94,7 @@ async function fixture(population = 10, withTleilaxu = false) {
const store = unitStore();
const made = await store.rooms.createRoom(
'Tupile recovery QA',
'guild',
'choam',
true,
['ix'],
);
@@ -130,10 +130,9 @@ async function fixture(population = 10, withTleilaxu = false) {
assert.equal(g.status, 'playing');
for (const player of g.players) g.deck.push(...player.hand.splice(0));
const ids = g.players.map((p) => p.id);
// Explicit unsupported-faction seam after genuine Homeworld/Ix setup.
// This audits production persistence for Tupile sales, not CHOAM setup or its deck.
// The real CHOAM identity precedes Homeworld/Ix setup and its signed history.
// Stage only the conserved population and balance needed for this sale.
Object.assign(g.players[0], {
faction: 'choam',
name: 'CHOAM',
spice: 12,
reserves: population,
@@ -117,10 +117,8 @@ async function persisted(ambassador = false, native = 6) {
}
const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
const initial = await store.rooms.readRoom(code);
let encoded = JSON.stringify(ambassador ? caladanAmbassadorFixture() : caladanVictoryFixture());
for (const [index, id] of (ambassador ? ['a', 'g', 'ec', 'h', 'bg'] : ['a', 'g']).entries())
encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(auths[index].playerId));
let g: Game = JSON.parse(encoded);
const seatIds = Object.fromEntries((ambassador ? ['a','g','ec','h','bg'] : ['a','g']).map((id,index) => [id,auths[index].playerId]));
let g: Game = ambassador ? caladanAmbassadorFixture(seatIds) : caladanVictoryFixture({seatIds});
g.code = code;
g.host = auths[0].playerId;
g.version = initial.version;
+172
View File
@@ -0,0 +1,172 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, ixDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { quoteTupileIntelligenceAnswer as answer } from '../game/tupile-intelligence-answer';
const inventory = () => [
...baseDeck(),
...ixDeck(),
...richeseCards(),
...ecazTreacheryCards(),
];
const card = (id: string) => inventory().find((c) => c.id === id)!;
void test('every registered physical Treachery card has its printed primary category, independently of activation', () => {
const weapons = new Set([
...Array.from({ length: 9 }, (_, n) => `treachery-${n}`),
'ix-poison-tooth',
'ix-artillery',
'ix-poison-blade',
'ix-weirding-way',
'ix-hunter-seeker',
'ix-basilia-weapon',
'richese-stone-burner',
'richese-mirror-weapon',
]);
const defenses = new Set([
...Array.from({ length: 8 }, (_, n) => `treachery-${n + 9}`),
'ix-shield-snooper',
'ix-chemistry',
'ix-shield',
'ix-snooper',
'richese-portable-snooper',
]);
const cards = inventory();
assert.equal(cards.length, 60);
assert.equal(new Set(cards.map((c) => c.id)).size, 60);
for (const c of cards) {
assert.deepEqual(
answer([c], 7, 'weapons'),
{ spice: 7, count: Number(weapons.has(c.id)) },
c.id,
);
assert.deepEqual(
answer([c], 7, 'defenses'),
{ spice: 7, count: Number(defenses.has(c.id)) },
c.id,
);
}
assert.deepEqual(answer(cards, 0, 'weapons'), { spice: 0, count: 17 });
assert.deepEqual(answer(cards, 0, 'defenses'), { spice: 0, count: 13 });
});
void test('hybrids count once in their default printed category, while slot-compatible noncombat cards count neither', () => {
const hybrids = [
'ix-poison-blade',
'ix-shield-snooper',
'ix-weirding-way',
'ix-chemistry',
].map(card);
assert.deepEqual(answer(hybrids, 2, 'weapons'), { spice: 2, count: 2 });
assert.deepEqual(answer(hybrids, 2, 'defenses'), { spice: 2, count: 2 });
const neither = inventory().filter(
(c) =>
c.kind === 'worthless' || c.kind === 'hero' || c.id.startsWith('ecaz-'),
);
assert.equal(neither.length, 11);
assert.equal(answer(neither, 4, 'weapons').count, 0);
assert.equal(answer(neither, 4, 'defenses').count, 0);
for (const c of hybrids)
Object.defineProperty(c, 'battleRole', {
get() {
throw new Error('Held count must not inspect a committed battle role');
},
});
assert.equal(answer(hybrids, 2, 'weapons').count, 2);
});
void test('Richese transport specials use canonical physical printed categories, not name or effect guesses', () => {
const cards = richeseCards();
assert.ok(cards.every((c) => c.kind === 'special'));
assert.equal(answer(cards, 3, 'weapons').count, 2);
assert.equal(answer(cards, 3, 'defenses').count, 1);
assert.equal(
answer([card('richese-residual-poison')], 3, 'weapons').count,
0,
);
for (const forged of [
{ ...card('richese-residual-poison'), effect: 'stoneBurner' },
{ ...card('richese-stone-burner'), id: 'forged-weapon' },
{ ...card('richese-mirror-weapon'), kind: 'poison' },
{ ...card('richese-portable-snooper'), name: 'Snooper' },
])
assert.throws(() => answer([forged as Card], 3, 'weapons'));
});
void test('same-name physical copies count separately; duplicate IDs and invented leader-skill cards reject', () => {
const shields = inventory().filter((c) => c.name === 'Shield');
const sparse: Card[] = [];
sparse.length = 2;
assert.equal(answer(shields, 0, 'defenses').count, 5);
for (const invalid of [
[shields[0], shields[0]],
[shields[0], { ...shields[0] }],
[
{
id: 'leader-skill-master-of-assassins',
name: 'Master of Assassins',
kind: 'poison',
},
],
[{ ...card('treachery-0'), id: 'treachery-999' }],
[null],
sparse,
])
assert.throws(() => answer(invalid as Card[], 0, 'weapons'));
});
void test('zero and safe balances are truthful, malformed requests reject without partial private answers or mutation', () => {
const hand = [card('treachery-0')];
assert.deepEqual(answer([], 0, 'defenses'), { spice: 0, count: 0 });
assert.deepEqual(answer(hand, Number.MAX_SAFE_INTEGER, 'weapons'), {
spice: Number.MAX_SAFE_INTEGER,
count: 1,
});
for (const spice of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])
assert.throws(() => answer(hand, spice, 'weapons'));
for (const category of ['both', 'weapon', null, 1])
assert.throws(() => answer(hand, 0, category as 'weapons'));
const corrupt = [
hand[0],
{ ...card('richese-stone-burner'), name: 'Private corrupt face' },
];
const before = JSON.stringify(corrupt);
assert.throws(
() => answer(corrupt, 109, 'weapons'),
(error: Error) => {
assert.doesNotMatch(
error.message,
/treachery|richese|109|Private corrupt face/,
);
return true;
},
);
assert.equal(JSON.stringify(corrupt), before);
});
void test('the answer is a detached two-number snapshot across hand changes, JSON restore and frozen inputs', () => {
const hand = [card('treachery-0'), card('treachery-9')];
const receipt = answer(hand, 12, 'weapons');
assert.deepEqual(Object.keys(receipt).sort(), ['count', 'spice']);
assert.deepEqual(JSON.parse(JSON.stringify(receipt)), {
spice: 12,
count: 1,
});
hand.splice(0, 1);
assert.equal(answer(hand, 1, 'weapons').count, 0);
assert.deepEqual(receipt, { spice: 12, count: 1 });
Object.freeze(hand[0]);
Object.freeze(hand);
assert.deepEqual(answer(hand, 1, 'defenses'), { spice: 1, count: 1 });
});
void test('the documented legacy Ellaca Drug name is recognized without changing its saved identity or the distinct Ix weapon', () => {
const legacy = { ...card('treachery-7'), name: 'Basilia Weapon' };
const hand = [legacy, card('ix-basilia-weapon')];
const before = JSON.stringify(hand);
assert.equal(answer(hand, 6, 'weapons').count, 2);
assert.equal(JSON.stringify(hand), before);
assert.throws(() => answer([{ ...legacy, kind: 'shield' }], 6, 'defenses'));
});
+158
View File
@@ -0,0 +1,158 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TupileIntelligence } from '../components/tupile-intelligence';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import {
tupileIntelligenceActions,
tupileIntelligenceCanAct,
tupileIntelligenceChoice,
} from '../game/tupile-intelligence-options';
/** Projected facts isolate client/policy behavior. The engine supplies contact,
* lifetime use, timing and private answer custody; the client cannot infer them. */
function fixture(): GameView {
const g = createGame('TUPILECONTROLS', newPlayer('c', 'CHOAM', 'choam'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 2, turn: 3, active: 'a', order: ['a', 'e', 'c'], storm: 18 });
const view = viewGame(g, 'c');
view.tupileIntelligence = {
owner: 'c', blocked: null,
targets: [
{ player: 'a', faction: 'atreides', contact: ['homeworld:atreides'], blocked: null },
{ player: 'e', faction: 'emperor', contact: ['homeworld:emperor:salusa'], blocked: null },
],
receipts: [],
};
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(TupileIntelligence, { game, busy, act() {} }));
}
void test('Tupile choice emits exactly one selected category for a current eligible target without mutation', () => {
const view = fixture();
const before = structuredClone(view);
for (const category of ['weapons', 'defenses'] as const)
assert.deepEqual(tupileIntelligenceChoice(view, 'e', category), {
blocked: null, action: { type: 'tupileIntelligence', target: 'e', category },
});
for (const target of ['c', 'missing'])
assert.equal(tupileIntelligenceChoice(view, target, 'weapons').action, null);
assert.equal(tupileIntelligenceChoice(view, 'a', 'both' as never).action, null);
assert.deepEqual(view, before);
view.tupileIntelligence!.targets.push({ ...view.tupileIntelligence!.targets[0] });
assert.equal(tupileIntelligenceChoice(view, 'a', 'weapons').action, null);
});
void test('all policy profiles skip spent or ineligible factions and obey the server timing block', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = fixture();
view.players.find((player) => player.id === view.me)!.bot = profile;
view.tupileIntelligence!.targets[0].blocked = 'Already used against Atreides in this game.';
assert.deepEqual(tupileIntelligenceActions(view), [
{ type: 'tupileIntelligence', target: 'e', category: 'weapons' },
]);
view.tupileIntelligence!.blocked = 'Finish the current response before requesting intelligence.';
assert.equal(tupileIntelligenceCanAct(view), false);
assert.deepEqual(tupileIntelligenceActions(view), []);
assert.equal(tupileIntelligenceChoice(view, 'e', 'defenses').blocked, view.tupileIntelligence!.blocked);
view.tupileIntelligence!.blocked = null;
view.tupileIntelligence!.targets[1].blocked = 'No current Homeworld contact.';
assert.deepEqual(tupileIntelligenceActions(view), []);
}
});
void test('owned controls expose accessible opponent/category selection and one request button', () => {
const html = markup(fixture());
assert.match(html, /aria-label="Opposing faction"/);
assert.match(html, /aria-label="Card count"/);
assert.match(html, /value="weapons" selected=""/);
assert.match(html, /value="defenses"/);
assert.match(html, />Atreides<\/option>/);
assert.match(html, />Emperor<\/option>/);
assert.equal((html.match(/<button\b/g) ?? []).length, 1);
assert.match(html, />Request intelligence<\/button>/);
assert.doesNotMatch(html, /confirm|Reveal hand|Your private observations/i);
});
void test('private historical observations remain readable during a block and after JSON restoration', () => {
const view = fixture();
view.tupileIntelligence!.blocked = 'Occupation must be resolved before another request.';
view.tupileIntelligence!.receipts = [
{ event: 'old-a', target: 'a', faction: 'atreides', category: 'weapons', spice: 13, count: 2, turn: 1, phase: 3 },
{ event: 'old-e', target: 'e', faction: 'emperor', category: 'defenses', spice: 0, count: 1, turn: 2, phase: 7 },
];
const before = structuredClone(view);
const html = markup(JSON.parse(JSON.stringify(view)));
assert.match(html, /Your private observations/);
assert.match(html, /13 spice, 2 weapons/);
assert.match(html, /0 spice, 1 defense/);
assert.match(html, /Turn 1 · Bidding/);
assert.match(html, /Turn 2 · Spice collection/);
assert.ok(html.indexOf('0 spice, 1 defense') < html.indexOf('13 spice, 2 weapons'));
assert.match(html, /do not update/);
assert.match(html, /Occupation must be resolved/);
assert.match(html, /<button[^>]* disabled=""[^>]*>Request intelligence/);
assert.deepEqual(view, before);
});
void test('busy, empty and spent-target controls cannot submit a new request', () => {
const busy = markup(fixture(), true);
assert.equal((busy.match(/<select[^>]* disabled=""/g) ?? []).length, 2);
assert.match(busy, /<button[^>]* disabled=""[^>]*>Request intelligence/);
const view = fixture();
view.tupileIntelligence!.targets = [];
assert.match(markup(view), /No eligible faction/);
assert.match(markup(view), /No faction is currently available for a request/);
assert.doesNotMatch(markup(view), /Choose a current opposing faction/);
assert.match(markup(view), /<button[^>]* disabled=""[^>]*>Request intelligence/);
assert.deepEqual(tupileIntelligenceActions(view), []);
const spent = fixture();
spent.tupileIntelligence!.targets[0].blocked = 'Already used against this faction in this game.';
spent.tupileIntelligence!.targets[1].blocked = 'CHOAM must be on that faction’s Homeworld, or that faction must be on Tupile.';
spent.tupileIntelligence!.blocked = 'Finish the current response before requesting intelligence.';
spent.tupileIntelligence!.receipts = [
{ event: 'spent-a', target: 'a', faction: 'atreides', category: 'weapons', spice: 7, count: 1, turn: 1, phase: 5 },
];
const unavailable = markup(spent);
assert.match(unavailable, /No eligible faction/);
assert.match(unavailable, /Atreides<\/strong>: Already used against this faction in this game/);
assert.match(unavailable, /Emperor<\/strong>: CHOAM must be on that faction/);
assert.match(unavailable, /Finish the current response before requesting intelligence/);
assert.match(unavailable, /7 spice, 1 weapon/);
assert.doesNotMatch(unavailable, /Choose a current opposing faction/);
assert.deepEqual(tupileIntelligenceActions(spent), []);
});
void test('other seats cannot render or consult private offers, even if a malformed projection includes one', () => {
const view = fixture();
view.me = 'a';
for (const field of ['targets', 'receipts'] as const)
Object.defineProperty(view.tupileIntelligence!, field, { get() { throw new Error(`Private ${field} accessed`); } });
assert.equal(markup(view), '');
assert.equal(tupileIntelligenceCanAct(view), false);
assert.deepEqual(tupileIntelligenceActions(view), []);
assert.equal(tupileIntelligenceChoice(view, 'e', 'weapons').action, null);
const missing = fixture();
missing.tupileIntelligence = null;
assert.equal(markup(missing), '');
assert.deepEqual(tupileIntelligenceActions(missing), []);
});
void test('policy and panel never inspect opponent hands, balances, plans or private source state', () => {
const view = fixture();
const expected = markup(view);
Object.defineProperty(view, 'players', { get() { throw new Error('Player source inspected'); } });
for (const field of ['hand', 'spice', 'battle', 'homeworlds', 'noField', 'leaders'])
Object.defineProperty(view, field, { get() { throw new Error(`Hidden ${field} inspected`); } });
assert.equal(markup(view), expected);
Object.defineProperty(view.tupileIntelligence!, 'receipts', { get() { throw new Error('History used for eligibility'); } });
assert.deepEqual(tupileIntelligenceActions(view), [{ type: 'tupileIntelligence', target: 'a', category: 'weapons' }]);
assert.ok(tupileIntelligenceChoice(view, 'e', 'defenses').action);
view.status = 'finished';
assert.deepEqual(tupileIntelligenceActions(view), []);
assert.equal(tupileIntelligenceChoice(view, 'e', 'defenses').action, null);
});
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
holdTupileCard, positionTupileContact, tupileIntelligenceFixture,
tupileInventory, tupilePlayer, tupileReject, tupileReload,
} from './fixture-tupile-intelligence';
const request = (category = 'weapons', target = 'a') => ({ type: 'tupileIntelligence', target, category });
function heldAnswer(g: Game): void {
for (const kind of ['projectile', 'shield', 'worthless', 'ghola']) holdTupileCard(g, 'a', kind);
tupilePlayer(g, 'a').spice = 13;
}
void test('actual Tupile contact grants one private snapshot off turn, without a response or material cost', () => {
for (const advanced of [false, true]) {
const g = tupileIntelligenceFixture({ advanced });
heldAnswer(g);
g.active = 'h';
const before = tupileReload(g);
const next = applyAction(g, 'c', request());
assert.deepEqual(g, before);
const receipt = viewGame(next, 'c').tupileIntelligence!.receipts[0];
assert.deepEqual({ ...receipt, event: 'event' }, {
event: 'event', target: 'a', faction: 'atreides', category: 'weapons', spice: 13, count: 1, turn: 2, phase: 5,
});
assert.deepEqual(next.players, before.players);
assert.deepEqual(next.homeworlds, before.homeworlds);
assert.equal(next.active, 'h');
assert.equal(next.response, null);
assert.equal(next.decision, null);
assert.equal(next.tupileIntelligence!.receipts.length, 1);
assert.deepEqual(next.homeworldOccupationHistory!.qualifications, []);
tupileInventory(next);
}
});
void test('all seat views keep the unchosen count and private receipt out of opponents and public logs', () => {
const g = tupileIntelligenceFixture();
heldAnswer(g);
const next = applyAction(g, 'c', request('defenses'));
const receipt = next.tupileIntelligence!.receipts[0];
for (const id of ['c', 'a', 'h']) {
const view = viewGame(next, id);
assert.deepEqual(Object.keys(view.tupileIntelligence?.receipts[0] ?? {}).sort(), id === 'c'
? ['category', 'count', 'event', 'faction', 'phase', 'spice', 'target', 'turn'] : []);
if (id !== 'c') {
assert.equal(view.tupileIntelligence, null);
assert.equal(JSON.stringify(view).includes(receipt.event), false);
assert.equal(JSON.stringify(view).includes(receipt.signature), false);
}
for (const other of view.players.filter((p) => p.id !== id)) assert.equal(other.hand, undefined);
}
const publicLog = JSON.stringify(next.log.slice(g.log.length));
assert.match(publicLog, /Tupile intelligence/);
assert.doesNotMatch(publicLog, /13|defenses|weapons|"count"|"spice"/);
});
void test('a foreign arrival on garrisoned Tupile grants contact independently for each opposing faction', () => {
let g = tupileIntelligenceFixture({ contact: false });
for (const [id, faction] of [['a', 'atreides'], ['h', 'harkonnen']]) {
g.active = id;
g = applyAction(g, id, {
type: 'homeworldShip', event: viewGame(g, id).homeworldShipment!.event,
destination: 'homeworld:choam', sources: { [`homeworld:${faction}`]: { normal: 1, elite: 0 } },
});
assert.deepEqual(g.homeworldOccupationHistory!.qualifications, []);
const target = viewGame(g, 'c').tupileIntelligence!.targets.find((seat) => seat.player === id)!;
assert.deepEqual(target.contact, ['homeworld:choam']);
assert.equal(target.blocked, null);
g = applyAction(g, 'c', request('defenses', id));
}
assert.deepEqual(g.tupileIntelligence!.receipts.map((receipt) => receipt.faction), ['atreides', 'harkonnen']);
assert.equal(tupilePlayer(g, 'c').reserves, 10);
tupileInventory(g);
});
void test('the lifetime faction use survives category changes, later phases, departure and reentry', () => {
let g = applyAction(tupileIntelligenceFixture(), 'c', request());
for (const category of ['weapons', 'defenses']) tupileReject(g, 'c', request(category), /already been used/);
positionTupileContact(g, false);
g.turn = 3;
g.phase = 2;
positionTupileContact(g, true);
g = tupileReload(g);
assert.equal(viewGame(g, 'c').tupileIntelligence!.receipts.length, 1);
tupileReject(g, 'c', request('defenses'), /already been used/);
assert.deepEqual(normalizeAutomaticGame(g).tupileIntelligence, g.tupileIntelligence);
tupileInventory(g);
});
void test('JSON reload retains the historical answer when the target spends spice and exchanges held cards', () => {
let g = tupileIntelligenceFixture();
heldAnswer(g);
g = applyAction(g, 'c', request());
const answer = structuredClone(viewGame(g, 'c').tupileIntelligence!.receipts);
const target = tupilePlayer(g, 'a');
target.spice = 0;
g.deck.push(...target.hand.splice(0));
holdTupileCard(g, 'a', 'shield');
positionTupileContact(g, false);
g = normalizeAutomaticGame(tupileReload(g));
assert.deepEqual(viewGame(g, 'c').tupileIntelligence!.receipts, answer);
assert.equal(target.hand.length, 1);
tupileInventory(g);
});
void test('current population, contact, pending timing and malformed requests reject without changing the game', () => {
const g = tupileIntelligenceFixture();
const high = tupileReload(g);
tupilePlayer(high, 'c').reserves += 2;
tupilePlayer(high, 'c').forces['polar_sink:0'] -= 2;
tupileReject(high, 'c', request(), /at most ten/);
const absent = tupileReload(g);
positionTupileContact(absent, false);
tupileReject(absent, 'c', request(), /must be on/);
const opening = tupileReload(g);
opening.phaseOpening = { passed: [], initialize: false };
assert.match(viewGame(opening, 'c').tupileIntelligence!.blocked!, /Finish/);
tupileReject(opening, 'c', request(), /Finish/);
tupileReject(g, 'a', request(), /Only CHOAM/);
for (const action of [request('both'), request('weapons', 'c'), request('weapons', 'missing'), { ...request(), count: 0 }, { ...request(), spice: 0 }])
tupileReject(g, 'c', action);
assert.deepEqual(g.tupileIntelligence!.receipts, []);
});
void test('deleted receipts or mismatched initialized history reject projection, normalization and actions immutably', () => {
const original = applyAction(tupileIntelligenceFixture(), 'c', request());
const corruptions: ((g: Game) => void)[] = [
(g) => { g.tupileIntelligence!.receipts = []; },
(g) => { g.tupileIntelligence!.receipts[0].count++; },
(g) => { g.tupileIntelligence!.receipts[0].category = 'defenses'; },
(g) => { delete g.tupileIntelligence; },
(g) => { delete g.homeworldOccupationHistory; },
(g) => { delete g.homeworlds!.historyVersion; },
];
for (const corrupt of corruptions) {
const broken = tupileReload(original);
corrupt(broken);
const before = tupileReload(broken);
for (const id of ['c', 'a', 'h']) assert.throws(() => viewGame(broken, id), /ledger|history|observation/i);
assert.throws(() => normalizeAutomaticGame(broken), /ledger|history|observation/i);
tupileReject(broken, 'c', request('defenses'), /ledger|history|observation/i);
assert.deepEqual(broken, before);
}
});
void test('legacy saves with no initialized history remain readable but cannot invent a fresh intelligence allowance', () => {
const g = tupileIntelligenceFixture();
delete g.homeworlds!.historyVersion;
delete g.homeworldOccupationHistory;
delete g.tupileIntelligence;
const before = tupileReload(g);
for (const id of ['c', 'a', 'h']) {
const view = viewGame(g, id);
if (id === 'c') {
assert.match(view.tupileIntelligence!.blocked!, /lacks the original/);
assert.deepEqual(view.tupileIntelligence!.receipts, []);
} else assert.equal(view.tupileIntelligence, null);
}
tupileReject(g, 'c', request(), /lacks the original/);
const normalized = normalizeAutomaticGame(g);
assert.equal(normalized.tupileIntelligence, undefined);
assert.equal(normalized.homeworldOccupationHistory, undefined);
assert.deepEqual(g, before);
});
void test('every real bot profile emits a legal owned request and stops after using that faction', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const g = tupileIntelligenceFixture();
g.active = 'a';
tupilePlayer(g, 'c').bot = profile;
const actions = botActions(viewGame(g, 'c'));
assert.deepEqual(actions, [request()]);
const next = applyAction(g, 'c', actions[0]);
assert.equal(next.tupileIntelligence!.receipts.length, 1);
assert.equal(botActions(viewGame(next, 'c')).some((action) => action.type === 'tupileIntelligence'), false);
}
});
+550
View File
@@ -0,0 +1,550 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, webcrypto } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import type * as Rooms from '../db/rooms';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldContext } from '../game/homeworld-game';
import { tupileOccupationStatus } from '../game/homeworld-occupation-history';
import {
tupileIntelligenceFixture,
tupileInventory,
tupilePlayer as player,
tupileReload as reload,
holdTupileCard,
} from './fixture-tupile-intelligence';
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
function unitStore(runBots = bots.runBots) {
const sqlite = new DatabaseSync(':memory:');
const hooks: { beforeWrite?: () => Promise<void> } = {};
const writes: { expected: number; changes: number }[] = [];
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((f) => f.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async run() {
const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
);
if (continuation)
writes.push({ expected: Number(this.values[3]), changes });
return { meta: { changes } };
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
sqlite.exec('BEGIN');
try {
const results = [];
for (const statement of statements) results.push(await statement.run());
sqlite.exec('COMMIT');
return results;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
function loadRooms() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
// Keep persisted JSON values in the test realm for strict structural assertions.
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine;
if (name === '@/game/bots') return { ...bots, runBots };
throw new Error('Unexpected module ' + name);
},
},
);
return exports as typeof Rooms;
}
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
}
type Game = engine.Game;
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
async function persisted() {
const store = unitStore();
const made = await store.rooms.createRoom(
'Tupile intelligence SQL QA',
'choam',
false,
[],
);
const code = made.view.code;
const credentials = [made.token];
for (const faction of ['atreides', 'harkonnen'] as const) {
const joined = await store.rooms.joinRoom(code, faction, faction);
credentials.push(joined.token!);
}
const auths = await Promise.all(
credentials.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const initial = tupileIntelligenceFixture({ seatIds: ids, contact: false });
initial.code = code;
initial.host = ids[0];
initial.version = (await store.rooms.readRoom(code)).version;
holdTupileCard(initial, ids[1], 'projectile');
holdTupileCard(initial, ids[1], 'poison');
holdTupileCard(initial, ids[1], 'shield');
player(initial, ids[1]).spice = 997;
const save = (state: Game) =>
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(state), state.version, code);
save(initial);
const other = await store.rooms.createRoom(
'Untouched Tupile room',
'fremen',
false,
[],
);
const otherBefore = await store.rooms.readRoom(other.view.code);
store.writes.length = 0;
return {
...store,
code,
credentials,
auths,
ids,
initial,
save,
otherCode: other.view.code,
otherBefore,
};
}
type Fixture = Awaited<ReturnType<typeof persisted>>;
function snapshot(f: Fixture) {
const row = f.sqlite
.prepare('SELECT state, version FROM rooms WHERE code = ?')
.get(f.code)!;
return {
version: row.version,
hash: createHash('sha256').update(String(row.state)).digest('hex'),
};
}
async function act(f: Fixture, g: Game, actor: string, action: engine.Action) {
await f.restart().act(
f.code,
f.auths.find((auth) => auth.playerId === actor)!,
g.version,
action,
clock,
);
return f.rooms.readRoom(f.code);
}
function request(
f: Fixture,
category: 'weapons' | 'defenses' = 'weapons',
): engine.Action {
return { type: 'tupileIntelligence', target: f.ids[1], category };
}
async function contact(f: Fixture) {
const owner = await f.rooms.readSeatView(f.code, f.auths[0]);
const g = await act(f, f.initial, f.ids[0], {
type: 'homeworldShip',
event: owner.homeworldShipment!.event,
destination: 'homeworld:atreides',
sources: { 'homeworld:choam': { normal: 1, elite: 0 } },
});
assert.equal(
g.homeworlds!.custody!.visitors['homeworld:atreides'][f.ids[0]].normal,
1,
);
assert.equal(g.homeworldOccupationHistory!.qualifications.length, 0);
assert.equal(
(
await f.rooms.readSeatView(f.code, f.auths[0])
).tupileIntelligence!.targets.find((t) => t.player === f.ids[1])!.blocked,
null,
);
await restored(f, g);
f.writes.length = 0;
return g;
}
async function restored(f: Fixture, g: Game) {
const rooms = f.restart();
const before = snapshot(f);
for (const [index, token] of f.credentials.entries()) {
const auth = await rooms.authenticate(f.code, token);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, engine.viewGame(g, f.ids[index]));
for (const opponent of view.players.filter((p) => p.id !== auth.playerId)) {
assert.equal(opponent.hand, undefined);
assert.equal(opponent.spice, undefined);
}
if (index !== 0) {
assert.equal(view.tupileIntelligence, null);
// JSON export of another session's public/private view contains no CHOAM
// observation identity or private consistency payload.
const exported = JSON.stringify(view);
for (const receipt of g.tupileIntelligence?.receipts ?? []) {
assert.equal(exported.includes(receipt.event), false);
assert.equal(exported.includes(receipt.signature), false);
}
assert.equal(exported.includes('tupileIntelligenceObservation'), false);
}
for (const entry of view.log.filter((entry) =>
entry.text.includes('used Tupile intelligence'),
)) {
assert.doesNotMatch(
entry.text,
/997|weapons|defenses|count[=:]|balance[=:]/,
);
}
}
homeworldGameIntegrity(g);
tupileInventory(g);
assert.deepEqual(snapshot(f), before);
assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
return rooms;
}
function barrier(f: Fixture) {
let arrivals = 0;
let release!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await waiting;
};
}
void test('production SQL competing weapon and defense requests reveal one historical answer to CHOAM and consume the target once', async () => {
const f = await persisted();
try {
const g = await contact(f);
barrier(f);
const outcomes = await Promise.allSettled([
f.rooms.act(f.code, f.auths[0], g.version, request(f, 'weapons'), clock),
f
.restart()
.act(f.code, f.auths[0], g.version, request(f, 'defenses'), clock),
]);
delete f.hooks.beforeWrite;
assert.equal(
outcomes.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.rooms.readRoom(f.code);
assert.equal(done.version, g.version + 1);
assert.equal(done.tupileIntelligence!.receipts.length, 1);
const receipt = done.tupileIntelligence!.receipts[0];
assert.equal(receipt.spice, 997);
assert.equal(receipt.count, receipt.category === 'weapons' ? 2 : 1);
assert.equal(receipt.target, f.ids[1]);
assert.deepEqual(done.players, g.players);
assert.deepEqual(done.homeworlds, g.homeworlds);
const before = snapshot(f);
for (const category of ['weapons', 'defenses'] as const) {
await assert.rejects(
f.rooms.act(f.code, f.auths[0], g.version, request(f, category), clock),
);
await assert.rejects(
f.rooms.act(
f.code,
f.auths[0],
done.version,
request(f, category),
clock,
),
/already been used/,
);
}
assert.deepEqual(snapshot(f), before);
await restored(f, done);
} finally {
f.sqlite.close();
}
});
void test('production SQL a lost answer response is recovered privately and remains the original snapshot after target balance and hand change', async () => {
const f = await persisted();
try {
const g = await contact(f);
// Deliberately discard the successful response to model a lost network reply.
await f.rooms.act(f.code, f.auths[0], g.version, request(f), clock);
const done = await f.restart().readRoom(f.code);
const expected = structuredClone(done.tupileIntelligence!.receipts[0]);
assert.equal(expected.count, 2);
const changed = reload(done);
// A conserved later hand fixture proves stored observations do not follow
// current private data; it does not invent a second request or receipt.
changed.deck.push(...player(changed, f.ids[1]).hand.splice(0));
player(changed, f.ids[1]).spice = 13;
f.save(changed);
const rooms = await restored(f, changed);
const owner = await rooms.readSeatView(f.code, f.auths[0]);
assert.deepEqual(owner.tupileIntelligence!.receipts, [
{
event: expected.event,
target: expected.target,
faction: expected.faction,
category: 'weapons',
spice: 997,
count: 2,
turn: expected.turn,
phase: expected.phase,
},
]);
assert.deepEqual(changed.tupileIntelligence!.receipts[0], expected);
const before = snapshot(f);
f.writes.length = 0;
await assert.rejects(
rooms.act(
f.code,
f.auths[0],
changed.version,
request(f, 'defenses'),
clock,
),
/already been used/,
);
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(snapshot(f), before);
assert.equal(f.writes.length, 0);
} finally {
f.sqlite.close();
}
});
void test('production SQL corrupt answer, lifetime usage, setup marker or occupation evidence rejects every session without database writes', async () => {
const f = await persisted();
try {
const g = await contact(f);
const answered = await act(f, g, f.ids[0], request(f));
const cases: ((state: Game) => void)[] = [
(state) => {
state.tupileIntelligence!.receipts[0].count++;
},
(state) => {
state.tupileIntelligence!.receipts[0].spice++;
},
(state) => {
state.tupileIntelligence!.receipts[0].category = 'defenses';
},
(state) => {
state.tupileIntelligence!.receipts = [];
},
(state) => {
delete state.tupileIntelligence;
},
(state) => {
delete state.homeworlds!.historyVersion;
},
(state) => {
delete state.homeworldOccupationHistory;
},
(state) => {
state.homeworldOccupationHistory!.sources[0].event = 'another-setup';
},
(state) => {
state.homeworldOccupationHistory!.signature = 'changed-history';
},
];
for (const mutate of cases) {
const corrupt = reload(answered);
mutate(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = snapshot(f);
const rooms = f.restart();
for (const auth of f.auths)
await assert.rejects(
rooms.readSeatView(f.code, auth),
/Tupile|Homeworld|occupation/i,
);
await assert.rejects(
rooms.act(f.code, f.auths[0], corrupt.version, request(f), clock),
/Tupile|Homeworld|occupation/i,
);
const stateBefore = reload(corrupt);
assert.throws(
() => engine.normalizeAutomaticGame(corrupt),
/Tupile|Homeworld|occupation/i,
);
assert.deepEqual(corrupt, stateBefore);
assert.deepEqual(snapshot(f), before);
assert.equal(f.writes.length, 0);
assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
}
} finally {
f.sqlite.close();
}
});
void test('production SQL legacy history absence stays unknown on reads and normalization with no reconstructed intelligence', async () => {
const f = await persisted();
try {
const g = await contact(f);
// Explicit legacy save seam: this game predates all three initialization
// fields. Removing only one from an initialized game is tested above.
delete g.homeworlds!.historyVersion;
delete g.homeworldOccupationHistory;
delete g.tupileIntelligence;
f.save(g);
f.writes.length = 0;
const before = snapshot(f);
const rooms = await restored(f, g);
const view = await rooms.readSeatView(f.code, f.auths[0]);
assert.match(view.tupileIntelligence!.blocked!, /lacks.*history/);
assert.deepEqual(view.tupileIntelligence!.receipts, []);
assert.equal(
tupileOccupationStatus(
g.homeworldOccupationHistory,
homeworldContext(g),
g.turn,
),
'unknown',
);
const normalized = engine.normalizeAutomaticGame(reload(g));
assert.deepEqual(normalized, g);
await assert.rejects(
rooms.act(f.code, f.auths[0], g.version, request(f), clock),
/lacks.*history/,
);
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(snapshot(f), before);
assert.equal(f.writes.length, 0);
} finally {
f.sqlite.close();
}
});
void test('production SQL actual sole occupation suppresses Tupile after the qualifier departs without inventing a current controller', async () => {
const f = await persisted();
try {
let g = await contact(f);
assert.equal(
tupileOccupationStatus(
g.homeworldOccupationHistory,
homeworldContext(g),
g.turn,
),
'unoccupied',
);
// Conserved battle-position seam empties natives before a genuine invading
// Homeworld shipment. CHOAM keeps its real contact on Atreides throughout.
player(g, f.ids[0]).forces['polar_sink:0'] += player(g, f.ids[0]).reserves;
player(g, f.ids[0]).reserves = 0;
g.active = f.ids[2];
g.movementRemaining = [f.ids[2]];
player(g, f.ids[2]).shipped = false;
f.save(g);
const invasionView = await f.rooms.readSeatView(f.code, f.auths[2]);
g = await act(f, g, f.ids[2], {
type: 'homeworldShip',
event: invasionView.homeworldShipment!.event,
destination: 'homeworld:choam',
sources: { 'homeworld:harkonnen': { normal: 1, elite: 0 } },
});
const evidence = structuredClone(
g.homeworldOccupationHistory!.qualifications.filter(
(q) => q.world === 'homeworld:choam',
),
);
assert.deepEqual(
evidence.map((q) => [q.player, q.cause]),
[[f.ids[2], 'sole']],
);
assert.equal(
tupileOccupationStatus(
g.homeworldOccupationHistory,
homeworldContext(g),
g.turn,
),
'unknown',
);
const before = snapshot(f);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], g.version, request(f), clock),
/occupation/,
);
assert.deepEqual(snapshot(f), before);
// A later unused shipment is staged in the same turn so departure does not
// imply an invented turn-expiry policy. Departure to another foreign world
// is a real action; ordinary return to Harkonnen's own world stays gated.
player(g, f.ids[2]).shipped = false;
f.save(g);
const returnView = await f.rooms.readSeatView(f.code, f.auths[2]);
g = await act(f, g, f.ids[2], {
type: 'homeworldShip',
event: returnView.homeworldShipment!.event,
destination: 'homeworld:atreides',
sources: { 'homeworld:choam': { normal: 1, elite: 0 } },
});
assert.equal(
g.homeworlds!.custody!.visitors['homeworld:choam']?.[f.ids[2]],
undefined,
);
assert.deepEqual(
g.homeworldOccupationHistory!.qualifications.filter(
(q) => q.world === 'homeworld:choam',
),
evidence,
);
assert.equal(
tupileOccupationStatus(
g.homeworldOccupationHistory,
homeworldContext(g),
g.turn,
),
'unknown',
);
const departed = snapshot(f);
f.writes.length = 0;
await assert.rejects(
f.restart().act(f.code, f.auths[0], g.version, request(f), clock),
/occupation/,
);
await restored(f, g);
assert.deepEqual(snapshot(f), departed);
assert.equal(f.writes.length, 0);
assert.deepEqual(g.tupileIntelligence!.receipts, []);
} finally {
f.sqlite.close();
}
});
+310
View File
@@ -0,0 +1,310 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
appendTupileIntelligenceObservation as append,
createTupileIntelligenceState as create,
validateTupileIntelligenceState as validate,
tupileIntelligenceUsedFactions as used,
tupileIntelligenceObservationSignature,
tupileIntelligenceStateSignature,
type TupileIntelligenceObservation,
type TupileIntelligenceState,
} from '../game/tupile-intelligence-state';
import type { FactionId } from '../game/catalog';
const players = [
{ id: 'c', faction: 'choam' as const },
{ id: 'e', faction: 'emperor' as const },
{ id: 'a', faction: 'atreides' as const },
];
function observation(): Omit<TupileIntelligenceObservation, 'signature'> {
return {
event: 'first-query',
owner: 'c',
target: 'e',
faction: 'emperor',
category: 'weapons',
spice: 17,
count: 2,
turn: 2,
phase: 6,
contact: [
'homeworld:emperor',
'homeworld:emperor:salusa',
'homeworld:choam',
],
};
}
function two() {
return append(append(create('c'), observation()), {
...observation(),
event: 'second-query',
target: 'a',
faction: 'atreides',
category: 'defenses',
turn: 3,
phase: 0,
spice: 0,
count: 0,
contact: ['homeworld:atreides'],
});
}
void test('new owner ledger and detached observations preserve a historical private answer through JSON and later turns', () => {
const empty = create('c');
validate(empty, players, 1);
assert.deepEqual(used(empty), []);
const input = observation();
const state = append(empty, input);
assert.equal(empty.receipts.length, 0);
assert.deepEqual(used(state), ['emperor']);
validate(
JSON.parse(JSON.stringify(state)) as TupileIntelligenceState,
players,
10,
);
input.spice = 0;
input.count = 0;
input.contact.pop();
assert.equal(state.receipts[0].spice, 17);
assert.equal(state.receipts[0].count, 2);
assert.equal(state.receipts[0].contact.length, 3);
assert.deepEqual(Object.keys(state).sort(), [
'owner',
'receipts',
'signature',
'version',
]);
});
void test('faction lifetime use never resets with another turn, phase, category, contact direction or target ID', () => {
const state = append(create('c'), observation());
const before = JSON.stringify(state);
for (const change of [
{ event: 'new-query' },
{ event: 'new-query', turn: 7, phase: 0 },
{ event: 'new-query', category: 'defenses' as const },
{ event: 'new-query', contact: ['homeworld:choam'] },
{ event: 'new-query', contact: ['homeworld:emperor:salusa'] },
{ event: 'new-query', target: 'new-emperor-seat' },
])
assert.throws(() => append(state, { ...observation(), ...change }));
assert.throws(() =>
append(state, {
...observation(),
target: 'a',
faction: 'atreides',
contact: ['homeworld:atreides'],
}),
);
assert.equal(JSON.stringify(state), before);
assert.deepEqual(used(two()), ['emperor', 'atreides']);
});
void test('only physical owner/target Homeworld IDs supply historical provenance, with both Emperor worlds allowed', () => {
for (const contact of [
['homeworld:choam'],
['homeworld:emperor'],
['homeworld:emperor:salusa'],
])
validate(append(create('c'), { ...observation(), contact }), players, 2);
for (const contact of [
[],
['homeworld:emperor', 'homeworld:emperor'],
['kaitain'],
['salusa_secundus'],
['homeworld:atreides'],
['arrakeen'],
['homeworld:choam:salusa'],
])
assert.throws(() => append(create('c'), { ...observation(), contact }));
assert.throws(() =>
append(create('c'), {
...observation(),
target: 'a',
faction: 'atreides',
contact: ['homeworld:emperor:salusa'],
}),
);
});
void test('every signed observation field and the ledger order, version and receipt membership resist corruption', () => {
const original = two();
const mutations: ((state: TupileIntelligenceState) => void)[] = [
(s) => {
s.owner = 'a';
},
(s) => {
s.version = 0 as 1;
},
(s) => {
s.receipts.pop();
},
(s) => {
s.receipts.reverse();
},
(s) => {
s.receipts[0].event = 'changed';
},
(s) => {
s.receipts[0].owner = 'a';
},
(s) => {
s.receipts[0].target = 'a';
},
(s) => {
s.receipts[0].faction = 'atreides';
},
(s) => {
s.receipts[0].category = 'defenses';
},
(s) => {
s.receipts[0].spice++;
},
(s) => {
s.receipts[0].count++;
},
(s) => {
s.receipts[0].turn++;
},
(s) => {
s.receipts[0].phase++;
},
(s) => {
s.receipts[0].contact.reverse();
},
(s) => {
delete (s.receipts[0] as Partial<TupileIntelligenceObservation>)
.signature;
},
(s) => {
delete (s as Partial<TupileIntelligenceState>).signature;
},
];
for (const mutate of mutations) {
const state = structuredClone(original);
mutate(state);
const before = JSON.stringify(state);
assert.throws(() => validate(state, players, 5));
assert.throws(() => used(state));
assert.equal(JSON.stringify(state), before);
}
// Object key order is not observation order and survives ordinary JSON tooling.
const reordered = JSON.parse(
JSON.stringify(original),
(_key, value: unknown) =>
value && typeof value === 'object' && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).reverse())
: value,
) as TupileIntelligenceState;
validate(reordered, players, 5);
});
void test('valid signatures cannot disguise invalid participants, duplicate faction use or a future observation', () => {
const state = append(create('c'), observation());
for (const seats of [
players.filter((p) => p.id !== 'e'),
players.map((p) =>
p.id === 'e' ? { ...p, faction: 'guild' as const } : p,
),
players.map((p) =>
p.id === 'c' ? { ...p, faction: 'guild' as const } : p,
),
[...players, { id: 'replacement', faction: 'emperor' as const }],
[...players, { id: 'a', faction: 'guild' as const }],
])
assert.throws(() => validate(state, seats, 2));
assert.throws(() => validate(state, players, 1));
for (const turn of [-1, 0, 1.5, Infinity])
assert.throws(() => validate(state, players, turn));
const duplicate = structuredClone(state);
const extra = {
...duplicate.receipts[0],
event: 'new-event',
target: 'new-target',
};
extra.signature = tupileIntelligenceObservationSignature(extra);
duplicate.receipts.push(extra);
duplicate.signature = tupileIntelligenceStateSignature(duplicate);
assert.throws(() => validate(duplicate, players, 2));
});
void test('malformed observations reject before append and never preserve a hidden hand or partial disclosure', () => {
const state = create('c');
const before = JSON.stringify(state);
for (const patch of [
{ event: '' },
{ owner: 'other' },
{ target: 'c' },
{ faction: 'choam' },
{ faction: 'invented' },
{ category: 'both' },
{ spice: -1 },
{ spice: NaN },
{ spice: Number.MAX_SAFE_INTEGER + 1 },
{ count: -1 },
{ count: 0.5 },
{ turn: 0 },
{ phase: 9 },
{ phase: -1 },
{ signature: 'wrong' },
{ hand: ['private-card'] },
]) {
assert.throws(() =>
append(state, { ...observation(), ...patch } as Parameters<
typeof append
>[1]),
);
assert.equal(JSON.stringify(state), before);
}
const maximum = append(state, {
...observation(),
spice: Number.MAX_SAFE_INTEGER,
count: Number.MAX_SAFE_INTEGER,
});
validate(maximum, players, 2);
assert.throws(() => create(' '));
assert.throws(() =>
validate(null as unknown as TupileIntelligenceState, players, 2),
);
});
void test('public validation reads no private fields and immutable inputs or returned use lists cannot alter receipts', () => {
const state = two();
const seats: { id: string; faction: FactionId }[] = structuredClone(players);
for (const seat of seats)
for (const field of [
'spice',
'hand',
'forces',
'reserves',
'traitors',
'battlePlan',
])
Object.defineProperty(seat, field, {
get() {
throw new Error(`Private ${field}`);
},
});
for (const receipt of state.receipts) {
Object.freeze(receipt.contact);
Object.freeze(receipt);
}
Object.freeze(state.receipts);
Object.freeze(state);
validate(state, seats, 4);
const factions = used(state);
factions.pop();
assert.deepEqual(used(state), ['emperor', 'atreides']);
const next = append(state, {
...observation(),
event: 'third',
target: 'g',
faction: 'guild',
contact: ['homeworld:guild'],
});
assert.notEqual(next.receipts[0], state.receipts[0]);
assert.notEqual(next.receipts[0].contact, state.receipts[0].contact);
assert.equal(state.receipts.length, 2);
validate(next, [...seats, { id: 'g', faction: 'guild' }], 4);
});