Implement private Tupile intelligence and Homeworld occupation evidence
This commit is contained in:
1 parent
1db11a0187
commit
61689514a0
43 files changed
+3420
-188
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user