Integrate Harkonnen Nexus traitor retirement and Mentat replacement
This commit is contained in:
1 parent
0c25aad62f
commit
1cf8ff72c7
25 files changed
+3049
-8
No files matched your search
@@ -7,6 +7,7 @@ import { projectAdminRoom, readAdminDirectory } from '../db/admin-directory';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { tableActionOwner } from '../game/table-turn';
|
||||
import { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-betrayal';
|
||||
import { createHarkonnenNexusBetrayalFixture } from './fixture-nexus-harkonnen-betrayal';
|
||||
|
||||
const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
|
||||
status, advanced, host: 'p1', turn: 1, phase: 0, expansions: [],
|
||||
@@ -54,6 +55,75 @@ void test('admin directory allowlist reveals no private state, role power or sea
|
||||
|
||||
const project = (value: unknown) => projectAdminRoom({ code: 'IXADMINA', version: 3, updated_at: 100, state: JSON.stringify(value) });
|
||||
|
||||
void test('Harkonnen acknowledgement ownership supersedes combatants and exposes only the unfinished public responder', () => {
|
||||
for (const advanced of [false, true]) for (const remote of [false, true]) {
|
||||
const f = createHarkonnenNexusBetrayalFixture({ advanced, remote, receiverCount: 2 });
|
||||
assert.equal(f.required.length, 2);
|
||||
const shared = project(f.game);
|
||||
assert.deepEqual(shared.pending.owners, []);
|
||||
for (const player of f.game.players)
|
||||
assert.equal(tableActionOwner(viewGame(f.game, player.id)), null);
|
||||
const partial = applyAction(f.game, f.holder, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
||||
const remaining = f.required.find(id => id !== f.holder)!;
|
||||
const sole = project(partial);
|
||||
assert.deepEqual(sole.pending.owners, [remaining]);
|
||||
for (const player of partial.players)
|
||||
assert.equal(tableActionOwner(viewGame(partial, player.id)), remaining);
|
||||
const privateChanges = {
|
||||
...partial,
|
||||
players: partial.players.map(player => ({ ...player, hand: ['SECRET_HAND'], traitors: ['SECRET_TRAITOR'], spice: 'SECRET_SPICE' })),
|
||||
traitorReserve: ['SECRET_RESERVE'],
|
||||
pendingNexusHarkonnenBetrayal: {
|
||||
...partial.pendingNexusHarkonnenBetrayal,
|
||||
source: { eligible: 'SECRET_ELIGIBILITY', parent: 'SECRET_PARENT', declaration: 'SECRET_DECLARATION' },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(project(privateChanges), sole);
|
||||
assert.equal(JSON.stringify(sole).includes('SECRET'), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Harkonnen admin progress is independent of private eligible and irrelevant Nexus faces', () => {
|
||||
for (const remote of [false, true]) {
|
||||
const eligible = createHarkonnenNexusBetrayalFixture({ advanced: true, remote, receiverCount: 2, face: 'harkonnen' });
|
||||
const irrelevant = createHarkonnenNexusBetrayalFixture({ advanced: true, remote, receiverCount: 2, face: 'richese' });
|
||||
assert.equal(viewGame(eligible.game, eligible.holder).nexusHarkonnenBetrayalReaction!.canUse, true);
|
||||
assert.equal(viewGame(irrelevant.game, irrelevant.holder).nexusHarkonnenBetrayalReaction!.canUse, false);
|
||||
assert.deepEqual(project(eligible.game), project(irrelevant.game));
|
||||
const partial = (f: typeof eligible) => applyAction(f.game, f.holder, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
||||
assert.deepEqual(project(partial(eligible)), project(partial(irrelevant)));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Harkonnen acknowledgement metadata preserves higher pending priorities and neutral malformed membership', () => {
|
||||
const input = {
|
||||
...state('Harkonnen membership QA', 'playing'), phase: 6,
|
||||
battle: { attacker: 'p1', defender: 'UNSEATED_SECRET' },
|
||||
pendingNexusHarkonnenBetrayal: { required: ['p1'], passed: [], source: 'SECRET_SOURCE' },
|
||||
};
|
||||
for (const priority of [
|
||||
{ status: 'finished' }, { status: 'lobby' }, { status: 'setup' },
|
||||
{ pendingTreacheryDiscard: { card: 'SECRET_CARD' } },
|
||||
{ response: { kind: 'SECRET_RESPONSE' } }, { phaseOpening: { passed: [] } },
|
||||
{ truthtrance: { question: 'SECRET_QUESTION' } },
|
||||
{ decision: { player: 'p1', card: 'SECRET_CARD' } },
|
||||
]) {
|
||||
const value = { ...input, ...priority };
|
||||
assert.deepEqual(project(value), project({ ...value, pendingNexusHarkonnenBetrayal: null }));
|
||||
}
|
||||
for (const membership of [
|
||||
{ required: ['UNSEATED_SECRET'], passed: [] },
|
||||
{ required: [null, 17, { id: 'SECRET_OBJECT' }], passed: [] },
|
||||
{ required: [], passed: [] }, { required: ['p1'], passed: ['p1'] },
|
||||
{ required: 'SECRET_REQUIRED', passed: [] }, { required: ['p1'], passed: 'SECRET_PASSED' }, {},
|
||||
]) {
|
||||
const output = project({ ...input, pendingNexusHarkonnenBetrayal: { ...membership, source: 'SECRET_SOURCE' } });
|
||||
assert.deepEqual(output.pending.owners, []);
|
||||
assert.equal(output.status, 'playing');
|
||||
assert.equal(JSON.stringify(output).includes('SECRET'), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('directory ownership follows genuine shared and sole Ixian acknowledgements instead of the native bidder', () => {
|
||||
for (const kind of ['technology', 'bidding'] as const) {
|
||||
const f = createIxianNexusBetrayalFixture({ kind, receiverCount: 2 });
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
|
||||
import { createHarkonnenNexusBetrayalFixture } from './fixture-nexus-harkonnen-betrayal';
|
||||
|
||||
const scenarios = [
|
||||
{ advanced: false, remote: false },
|
||||
{ advanced: true, remote: false },
|
||||
{ advanced: false, remote: true },
|
||||
{ advanced: true, remote: true },
|
||||
] as const;
|
||||
|
||||
void test('every minimal policy uses the actual legal cancellation without any rival private estimates', () => {
|
||||
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
|
||||
const f = createHarkonnenNexusBetrayalFixture(scenario);
|
||||
const own = viewGame(f.game, f.holder);
|
||||
own.players.find(p => p.id === own.me)!.bot = difficulty;
|
||||
for (const rival of own.players.filter(p => p.id !== own.me)) {
|
||||
delete rival.hand;
|
||||
delete rival.traitors;
|
||||
delete rival.spice;
|
||||
}
|
||||
const actions = botActions(own);
|
||||
assert.deepEqual(actions, [{ type: 'nexusHarkonnenBetrayalUse', event: f.event }]);
|
||||
const result = applyAction(f.game, f.holder, actions[0]);
|
||||
assert.equal(viewGame(result, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
assert.equal(result.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.equal(result.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), false);
|
||||
assert.equal(result.pendingNexusHarkonnenReplacement!.identity, f.identity);
|
||||
assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('every policy passes an irrelevant face and waits after its acknowledgement or outside public membership', () => {
|
||||
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
|
||||
const f = createHarkonnenNexusBetrayalFixture({ ...scenario, face: 'richese', secondFace: 'guild', receiverCount: 2 });
|
||||
let game = f.game;
|
||||
for (const seat of game.players) {
|
||||
const own = viewGame(game, seat.id);
|
||||
own.players.find(p => p.id === own.me)!.bot = difficulty;
|
||||
const reaction = own.nexusHarkonnenBetrayalReaction;
|
||||
if (!reaction) break;
|
||||
const actions = botActions(own);
|
||||
if (!reaction.canPass || reaction.hasPassed) {
|
||||
assert.deepEqual(actions, []);
|
||||
continue;
|
||||
}
|
||||
assert.deepEqual(actions, [{ type: 'nexusHarkonnenBetrayalPass', event: f.event }]);
|
||||
game = applyAction(game, seat.id, actions[0]);
|
||||
const refreshed = viewGame(structuredClone(game), seat.id);
|
||||
refreshed.players.find(p => p.id === refreshed.me)!.bot = difficulty;
|
||||
if (refreshed.nexusHarkonnenBetrayalReaction) assert.deepEqual(botActions(refreshed), []);
|
||||
}
|
||||
assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder], 'richese');
|
||||
assert.equal(game.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), true);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement ?? null, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('canonical response permissions and room scheduling override hints under all four policies', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const f = createHarkonnenNexusBetrayalFixture();
|
||||
const own: GameView = viewGame(f.game, f.holder);
|
||||
own.players.find(p => p.id === own.me)!.bot = difficulty;
|
||||
for (const reason of ['blocked', 'canUse'] as const) {
|
||||
const blocked = structuredClone(own);
|
||||
if (reason === 'blocked') blocked.nexusHarkonnenBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
|
||||
else blocked.nexusHarkonnenBetrayalReaction!.canUse = false;
|
||||
assert.deepEqual(botActions(blocked), [{ type: 'nexusHarkonnenBetrayalPass', event: f.event }]);
|
||||
}
|
||||
for (const reason of ['canPass', 'hasPassed', 'event', 'paused', 'closed', 'automatic', 'finished', 'human', 'phaseOpening'] as const) {
|
||||
const blocked = structuredClone(own);
|
||||
if (reason === 'canPass') blocked.nexusHarkonnenBetrayalReaction!.canPass = false;
|
||||
else if (reason === 'hasPassed') blocked.nexusHarkonnenBetrayalReaction!.hasPassed = true;
|
||||
else if (reason === 'event') blocked.nexusHarkonnenBetrayalReaction!.event = '';
|
||||
else if (reason === 'paused' || reason === 'closed') blocked.roomControl = { ...DEFAULT_ROOM_CONTROL, [reason]: true };
|
||||
else if (reason === 'automatic') blocked.automaticContinuationPending = true;
|
||||
else if (reason === 'human') {
|
||||
const seat = blocked.players.find(p => p.id === blocked.me)!;
|
||||
delete seat.bot;
|
||||
delete seat.autopilot;
|
||||
} else if (reason === 'phaseOpening') blocked.phaseOpening = { passed: [] };
|
||||
else blocked.status = 'finished';
|
||||
assert.deepEqual(botActions(blocked), []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the original native allied counter remains the bot decision before any Nexus cancellation', () => {
|
||||
for (const difficulty of DIFFICULTIES) for (const advanced of [false, true]) {
|
||||
const f = createHarkonnenNexusBetrayalFixture({ remote: true, advanced });
|
||||
assert.ok(f.beforeNativeCounter.response);
|
||||
const native = f.beforeNativeCounter;
|
||||
for (const seat of native.players) {
|
||||
const own = viewGame(native, seat.id);
|
||||
own.players.find(p => p.id === own.me)!.bot = difficulty;
|
||||
assert.equal(own.nexusHarkonnenBetrayalReaction, null);
|
||||
for (const action of botActions(own)) {
|
||||
assert.notEqual(action.type, 'nexusHarkonnenBetrayalUse');
|
||||
assert.notEqual(action.type, 'nexusHarkonnenBetrayalPass');
|
||||
// Exercise actual counter legality, rather than inventing a second gate.
|
||||
const result = applyAction(native, seat.id, action);
|
||||
assert.equal(result.nexusCards!.cards!.hands[f.holder], 'harkonnen');
|
||||
assert.equal(result.pendingNexusHarkonnenReplacement ?? null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('paced native bot execution uses or acknowledges every required seat without unrelated fallthrough', () => {
|
||||
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) for (const face of ['harkonnen', 'richese'] as const) {
|
||||
const f = createHarkonnenNexusBetrayalFixture({ ...scenario, face, secondFace: 'guild', receiverCount: 2 });
|
||||
let game = f.game;
|
||||
for (const id of f.required) game = applyAction(game, id, { type: 'setAutopilot', difficulty });
|
||||
for (let step = 0; step < f.required.length && viewGame(game, f.holder).nexusHarkonnenBetrayalReaction; step++) {
|
||||
game = runBots(game, 1);
|
||||
}
|
||||
assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder], face === 'harkonnen' ? null : 'richese');
|
||||
assert.equal(game.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), face !== 'harkonnen');
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement?.identity ?? null, face === 'harkonnen' ? f.identity : null);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction,createGame,initializeHarkonnenNexusBetrayalGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine';
|
||||
import {botActions} from '../game/bots';
|
||||
import {TERRITORIES} from '../game/board';
|
||||
|
||||
export type HarkonnenBetrayalFixtureOptions = {initial?:Game;advanced?:boolean;remote?:boolean;face?:'harkonnen'|'richese';secondFace?:'harkonnen'|'richese'|'guild';receiverCount?:1|2;seatIds?:string[]};
|
||||
export type HarkonnenBetrayalFixture = {beforeCall:Game;beforeNativeCounter:Game;game:Game;event:string;provider:string;holder:string;required:string[];beneficiary:string;target:string;identity:string;callAction:Action;advanced:boolean;remote:boolean};
|
||||
export type HarkonnenBetrayalNativeStep = {actor:string;action:Action};
|
||||
|
||||
export function settleHarkonnenBetrayalNativeCounters(state:Game):Game {
|
||||
let game=state;
|
||||
for(let i=0;i<100;i++) {
|
||||
if(game.pendingNexusHarkonnenBetrayal||!game.response) return game;
|
||||
const seat=game.players.find(p=>!game.response!.passed.includes(p.id));
|
||||
assert.ok(seat);
|
||||
game=applyAction(game,seat.id,{type:'passResponse'});
|
||||
}
|
||||
throw new Error('Original Harkonnen native counters did not settle.');
|
||||
}
|
||||
export function nextHarkonnenBetrayalNativeStep(game:Game):HarkonnenBetrayalNativeStep {
|
||||
const frame=game.pendingNexusHarkonnenBetrayal;
|
||||
if(frame) return {actor:frame.required.find(id=>!frame.passed.includes(id))!,action:{type:'nexusHarkonnenBetrayalPass',event:frame.source.event}};
|
||||
if(game.nexusCards?.phase?.stage==='drawing') {
|
||||
const phase=game.nexusCards.phase,actor=phase.eligible.find(id=>!phase.done.includes(id))!;
|
||||
return {actor,action:{type:'nexusCardChoice',turn:phase.turn,card:game.nexusCards.cards!.hands[actor],choice:'keep',ownRedraws:0}};
|
||||
}
|
||||
if(game.pendingTreacheryDiscard) return {actor:game.players[0].id,action:{type:'advanceBots'}};
|
||||
if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}};
|
||||
if(game.phaseOpening) return {actor:game.players.find(p=>!game.phaseOpening!.passed.includes(p.id))!.id,action:{type:'ready'}};
|
||||
if(game.battle?.preparation && ['voice','prescience'].includes(game.battle.preparation.kind))
|
||||
return {actor:game.battle.preparation.owner,action:{type:'declineBattlePower'}};
|
||||
if(game.decision?.kind==='fullPlanOffer')
|
||||
return {actor:game.decision.player,action:{type:'decision',decline:true}};
|
||||
if(game.status==='playing'&&!game.decision) {
|
||||
if(game.phase===0&&game.stormPending===null) return {actor:game.stormDialers.find(id=>game.stormDials[id]===undefined)!,action:{type:'stormDial',amount:game.turn===1?0:1}};
|
||||
if(game.phase===3&&game.auction) return {actor:game.auction.active,action:{type:'passBid'}};
|
||||
if(game.phase===5) return {actor:game.active!,action:{type:'endMovement'}};
|
||||
if(game.battle?.revealed) {
|
||||
const voter=viewGame(game,game.players[0].id).battle!.traitorVoters.find(id=>game.battle!.traitorCalls[id]===undefined);
|
||||
if(voter) return {actor:voter,action:{type:'traitorCall',call:false}};
|
||||
}
|
||||
if(game.phase!==6||!game.active) {
|
||||
const seat=game.players.find(p=>!game.ready.includes(p.id));
|
||||
if(seat) return {actor:seat.id,action:{type:'ready'}};
|
||||
}
|
||||
}
|
||||
for(const p of game.players) {
|
||||
if(game.decision&&game.decision.player!==p.id) continue;
|
||||
const view=viewGame(game,p.id);
|
||||
view.players.find(s=>s.id===p.id)!.bot='Easy';
|
||||
const actions=botActions(view),action=actions.find(a=>a.type==='ready')??actions[0];
|
||||
if(action) return {actor:p.id,action};
|
||||
}
|
||||
throw new Error(`No native Harkonnen fixture step at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
function step(game:Game):Game {const next=nextHarkonnenBetrayalNativeStep(game);return applyAction(game,next.actor,next.action);}
|
||||
export function advanceHarkonnenBetrayalToMentat(state:Game):Game {
|
||||
let game=state;
|
||||
const turn=game.turn;
|
||||
assert.ok(!game.pendingNexusHarkonnenBetrayal,'Settle the public acknowledgement before advancing.');
|
||||
for(let i=0;game.phase!==8&&game.turn===turn&&game.status==='playing'&&i<500;i++) game=step(game);
|
||||
assert.equal(game.turn,turn);assert.equal(game.phase,8);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement,null);
|
||||
return game;
|
||||
}
|
||||
/** Only original fresh setup randomness is deterministic. No replacement draw or
|
||||
* shuffle is mocked, and an already-started setup is never reinitialized. */
|
||||
function setup(game:Game):Game {
|
||||
const original=Object.getOwnPropertyDescriptor(crypto,'getRandomValues');
|
||||
let seed=142;
|
||||
crypto.getRandomValues=<T extends ArrayBufferView | null>(array:T):T=>{
|
||||
assert.ok(array);
|
||||
const bytes=new Uint8Array(array.buffer,array.byteOffset,array.byteLength);
|
||||
for(let i=0;i<bytes.length;i++) {seed=(Math.imul(seed,1664525)+1013904223)>>>0;bytes[i]=seed>>>24;}
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
if(game.status==='lobby') {
|
||||
for(const p of game.players) if(!p.ready) game=applyAction(game,p.id,{type:'ready'});
|
||||
game.nexusCards??={cards:null,phase:null};
|
||||
game=initializeHarkonnenNexusBetrayalGameForAudit(game);
|
||||
// Conserved starting-deal ordering ensures a real printed counter; BG
|
||||
// instead receives an actual Worthless Card for native Advanced conversion.
|
||||
const bg=game.advanced&&game.players.find(p=>p.faction==='beneGesserit');
|
||||
const originals=[...game.deck],first=originals.find(c=>bg?c.kind==='worthless':c.effect==='karama')!;
|
||||
assert.ok(first);
|
||||
const counterSeat=bg?bg.id:game.players.find(p=>p.faction!=='harkonnen')!.id;
|
||||
const seen=new Set<string>();
|
||||
const deal=game.players.flatMap(p=>Array.from({length:p.faction==='harkonnen'?2:1},(_,i)=>{
|
||||
const card=p.id===counterSeat&&i===0?first:
|
||||
originals.find(c=>c.id!==first.id&&!seen.has(c.id)&&c.kind==='worthless')??
|
||||
originals.find(c=>c.id!==first.id&&!seen.has(c.id))!;
|
||||
seen.add(card.id);return card;
|
||||
}));
|
||||
game.deck=[...deal,...originals.filter(c=>!seen.has(c.id))];
|
||||
}
|
||||
for(let i=0;game.status==='setup'&&i<150;i++) game=step(game);
|
||||
assert.equal(game.status,'playing');
|
||||
return game;
|
||||
} finally {
|
||||
if(original) Object.defineProperty(crypto,'getRandomValues',original);
|
||||
else Reflect.deleteProperty(crypto,'getRandomValues');
|
||||
}
|
||||
}
|
||||
/** Genuine setup, ordinary first turn, real second-turn Nexus/alliance and closing
|
||||
* draw. Only a conserved two-force battle is staged during native Movement;
|
||||
* its selection, sealed plans, call, counters and all continuation are native. */
|
||||
export function createHarkonnenNexusBetrayalFixture(options:HarkonnenBetrayalFixtureOptions={}):HarkonnenBetrayalFixture {
|
||||
const remote=options.remote??false,face=options.face??'harkonnen';
|
||||
let game:Game;
|
||||
if(options.initial) {
|
||||
game=structuredClone(options.initial);
|
||||
assert.ok(game.status==='lobby'||(game.status==='setup'&&game.nexusHarkonnenBetrayalPreview&&game.turn===1&&game.phase===0&&game.players.every(p=>p.hand.length===0)),'Only a fresh lobby or admitted undealt setup can continue.');
|
||||
if(options.advanced!==undefined) assert.equal(game.advanced,options.advanced);
|
||||
} else {
|
||||
const ids=options.seatIds??(options.receiverCount===2?['p0','p1','p2','p3']:['p0','p1','p2']);
|
||||
game=createGame('HARKONNENBETRAYAL',newPlayer(ids[0],'Atreides','atreides'),options.advanced??false);
|
||||
joinGame(game,newPlayer(ids[1],'Harkonnen','harkonnen'));
|
||||
joinGame(game,newPlayer(ids[2],'Guild','guild'));
|
||||
if(ids.length>3) joinGame(game,newPlayer(ids[3],'Emperor','emperor'));
|
||||
}
|
||||
game=setup(game);
|
||||
const provider=game.players.find(p=>p.faction==='harkonnen')!.id,owner=game.players.find(p=>p.id===provider)!;
|
||||
const targetPlayer=game.players.find(p=>p.id!==provider&&p.leaders.some(l=>owner.traitors.includes(l.id)));
|
||||
assert.ok(targetPlayer,'The preserved real setup has no opposing native leader in the Harkonnen hand.');
|
||||
const target=targetPlayer.id,holder=target;
|
||||
const identity=targetPlayer.leaders.find(l=>owner.traitors.includes(l.id))!.id;
|
||||
const second=options.receiverCount===2?game.players.find(p=>p.id!==provider&&p.id!==holder)!.id:null;
|
||||
const partners=game.players.filter(p=>p.id!==holder&&p.id!==second);
|
||||
assert.ok(partners.length>=2,'A real settled alliance needs two nonreceiver seats.');
|
||||
const partner=partners.find(p=>p.id!==provider)!;
|
||||
const beneficiary=remote?partner.id:provider;
|
||||
const nx=game.nexusCards!.cards!,faces:typeof nx.deck=[face,...(second?[options.secondFace??(face==='harkonnen'?'richese':'harkonnen')]:[])];
|
||||
assert.equal(new Set(faces).size,faces.length,'Each actual Nexus face remains a physical singleton.');
|
||||
nx.deck=[...faces,...nx.deck.filter(c=>!faces.includes(c))];
|
||||
for(let pos=0;pos<2;pos++) {const index=game.spiceDeck.findIndex((c,i)=>i>=pos&&'territory'in c);assert.ok(index>=pos);game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]);}
|
||||
for(let i=0;!(game.turn===2&&game.phase===0&&!game.response&&!game.decision)&&i<1500;i++) game=step(game);
|
||||
assert.equal(game.turn,2);
|
||||
while(game.phase!==1) game=step(game);
|
||||
const worm=game.spiceDeck.findIndex(c=>'worm'in c&&!c.greatMaker&&!c.suppressed);assert.ok(worm>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
|
||||
for(let i=0;!(game.nexus&&!game.spiceWindow&&!game.spiceResolution&&!game.response&&!game.decision)&&i<200;i++) game=step(game);
|
||||
assert.equal(game.nexus,true);
|
||||
game=applyAction(game,provider,{type:'alliance',target:partner.id});
|
||||
game=applyAction(game,partner.id,{type:'alliance',target:provider});
|
||||
for(let i=0;game.nexusCards?.phase?.stage!=='drawing'&&i<200;i++) game=step(game);
|
||||
assert.equal(game.nexusCards!.phase!.stage,'drawing');
|
||||
game=applyAction(game,holder,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
if(second) game=applyAction(game,second,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
while(game.nexusCards!.phase!.stage==='drawing') game=step(game);
|
||||
assert.equal(game.nexusCards!.cards!.hands[holder],face);
|
||||
for(let i=0;game.phase!==5&&i<300;i++) game=step(game);
|
||||
assert.equal(game.phase,5);
|
||||
const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))))!;
|
||||
assert.ok(territory);
|
||||
for(const id of [beneficiary,target]) {const p=game.players.find(s=>s.id===id)!;assert.ok(p.reserves>0);p.reserves--;p.forces[`${territory.id}:${territory.sectors[0]}`]=1;}
|
||||
while(game.phase===5) game=step(game);
|
||||
assert.equal(game.phase,6);
|
||||
const chooser=game.active!;assert.ok([beneficiary,target].includes(chooser));
|
||||
game=applyAction(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser===target?beneficiary:target});
|
||||
for(let i=0;(game.response||game.decision||game.battle?.preparation||(game.battle?.preLeader&&!game.battle.preLeader.closed))&&i<100;i++) game=step(game);
|
||||
for(const id of [game.battle!.attacker,game.battle!.defender]) {
|
||||
const p=game.players.find(s=>s.id===id)!;
|
||||
game=applyAction(game,id,{type:'battlePlan',dial:0,leader:id===target?identity:p.leaders.find(l=>!l.dead&&!l.usedAt)!.id});
|
||||
}
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
for(let i=0;(game.response||game.decision)&&i<50;i++) game=step(game);
|
||||
const beforeCall=structuredClone(game),callAction:Action={type:'traitorCall',call:true};
|
||||
game=applyAction(game,provider,callAction);
|
||||
const beforeNativeCounter=structuredClone(game);
|
||||
game=settleHarkonnenBetrayalNativeCounters(game);
|
||||
const frame=game.pendingNexusHarkonnenBetrayal;assert.ok(frame);
|
||||
return {beforeCall,beforeNativeCounter,game,event:frame.source.event,provider,holder,required:[...frame.required],beneficiary,target,identity,callAction,advanced:game.advanced,remote};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Children, createElement, isValidElement } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { tableActionOwner } from '../game/table-turn';
|
||||
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
|
||||
import { createHarkonnenNexusBetrayalFixture } from './fixture-nexus-harkonnen-betrayal';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
// Next/CSS loading hooks must precede the UI imports.
|
||||
const { NexusHarkonnenBetrayal } = await import('../components/nexus-harkonnen-betrayal');
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
|
||||
function click(node: ReactNode, selector: string): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{ children?: ReactNode; onClick?: () => void; 'data-testid'?: string }>(child)) continue;
|
||||
if (child.props['data-testid'] === selector && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children, selector)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function select(game: GameView, choice: 'use' | 'pass', busy = false): Action | undefined {
|
||||
let action: Action | undefined;
|
||||
click(NexusHarkonnenBetrayal({ game, busy, act(value) { action = value; } }), `nexus-harkonnen-betrayal-${choice}`);
|
||||
return action;
|
||||
}
|
||||
function markup(game: GameView): string {
|
||||
return renderToStaticMarkup(createElement(NexusHarkonnenBetrayal, { game, busy: false, act() {} }));
|
||||
}
|
||||
function table(game: GameView): string {
|
||||
return renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
|
||||
}
|
||||
const scenarios = [
|
||||
{ advanced: false, remote: false },
|
||||
{ advanced: true, remote: false },
|
||||
{ advanced: false, remote: true },
|
||||
{ advanced: true, remote: true },
|
||||
] as const;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
void test(`${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.remote ? 'allied' : 'personal'} human controls cancel the actual declared card or preserve it with Pass`, () => {
|
||||
const f = createHarkonnenNexusBetrayalFixture(scenario);
|
||||
const own = viewGame(f.game, f.holder);
|
||||
const use = select(own, 'use');
|
||||
assert.ok(use);
|
||||
assert.deepEqual(use, { type: 'nexusHarkonnenBetrayalUse', event: f.event });
|
||||
const denied = applyAction(f.game, f.holder, use);
|
||||
assert.equal(viewGame(denied, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
assert.equal(denied.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), false);
|
||||
assert.equal(denied.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.equal(denied.pendingNexusHarkonnenReplacement!.identity, f.identity);
|
||||
assert.equal(select(viewGame(denied, f.holder), 'use'), undefined);
|
||||
assert.throws(() => applyAction(denied, f.holder, use));
|
||||
|
||||
let allowed = f.game;
|
||||
for (const id of f.required) {
|
||||
const pass = select(viewGame(allowed, id), 'pass');
|
||||
assert.ok(pass);
|
||||
assert.deepEqual(pass, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
||||
allowed = applyAction(allowed, id, pass);
|
||||
}
|
||||
assert.equal(viewGame(allowed, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
assert.equal(allowed.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), true);
|
||||
assert.equal(allowed.nexusCards!.cards!.hands[f.holder], 'harkonnen');
|
||||
assert.equal(allowed.pendingNexusHarkonnenReplacement ?? null, null);
|
||||
});
|
||||
}
|
||||
|
||||
void test('the public declaration names only the actual card and roles; inspectors remain private to their own held face', () => {
|
||||
for (const scenario of scenarios) {
|
||||
const f = createHarkonnenNexusBetrayalFixture(scenario);
|
||||
const traitorName = viewGame(f.game, f.holder).allLeaders.find(l => l.id === f.identity)!.name;
|
||||
for (const seat of f.game.players) {
|
||||
const own = viewGame(f.game, seat.id);
|
||||
const html = markup(own);
|
||||
for (const id of [f.provider, f.beneficiary, f.target]) assert.ok(html.includes(f.game.players.find(p => p.id === id)!.name));
|
||||
assert.ok(html.includes(traitorName));
|
||||
if (own.nexusCards?.card === 'harkonnen') assert.ok(html.includes('Inspect Harkonnen Nexus card'));
|
||||
else assert.equal(html.includes('Inspect Harkonnen Nexus card'), false);
|
||||
if (seat.id !== f.provider) {
|
||||
for (const hidden of f.game.players.find(p => p.id === f.provider)!.traitors.filter(id => id !== f.identity)) {
|
||||
const hiddenName = own.allLeaders.find(l => l.id === hidden)!.name;
|
||||
assert.equal(html.includes(hiddenName), false);
|
||||
}
|
||||
}
|
||||
if (!f.required.includes(seat.id)) {
|
||||
assert.equal(select(own, 'use'), undefined);
|
||||
assert.equal(select(own, 'pass'), undefined);
|
||||
assert.equal(own.nexusHarkonnenBetrayalReaction!.blocked, null);
|
||||
}
|
||||
}
|
||||
const wrong = createHarkonnenNexusBetrayalFixture({ ...scenario, face: 'richese' });
|
||||
const ownWrong = viewGame(wrong.game, wrong.holder);
|
||||
assert.equal(ownWrong.nexusHarkonnenBetrayalReaction!.canPass, true);
|
||||
assert.equal(ownWrong.nexusHarkonnenBetrayalReaction!.blocked, null);
|
||||
assert.equal(select(ownWrong, 'use'), undefined);
|
||||
assert.ok(select(ownWrong, 'pass'));
|
||||
assert.equal(markup(ownWrong).includes('Inspect Harkonnen Nexus card'), false);
|
||||
assert.ok(markup(ownWrong).includes('Inspect Richese Nexus card'));
|
||||
}
|
||||
});
|
||||
|
||||
void test('partial acknowledgements use the sole public unfinished seat, not the provider or private face', () => {
|
||||
const f = createHarkonnenNexusBetrayalFixture({ receiverCount: 2 });
|
||||
for (const seat of f.game.players) assert.equal(tableActionOwner(viewGame(f.game, seat.id)), null);
|
||||
const first = f.required[0];
|
||||
const pass = select(viewGame(f.game, first), 'pass');
|
||||
assert.ok(pass);
|
||||
const next = applyAction(f.game, first, pass);
|
||||
for (const seat of next.players) assert.equal(tableActionOwner(viewGame(next, seat.id)), f.required[1]);
|
||||
const refreshed = viewGame(structuredClone(next), first);
|
||||
assert.equal(select(refreshed, 'pass'), undefined);
|
||||
assert.equal(select(refreshed, 'use'), undefined);
|
||||
assert.equal(select(viewGame(next, f.provider), 'use'), undefined);
|
||||
});
|
||||
|
||||
void test('table reserves the source and dispatch honors own response, room, transport and autopilot locks', () => {
|
||||
for (const scenario of scenarios) {
|
||||
const f = createHarkonnenNexusBetrayalFixture(scenario);
|
||||
const own: GameView = viewGame(f.game, f.holder);
|
||||
const html = table(own);
|
||||
assert.ok(html.includes('Declared Harkonnen traitor acknowledgement'));
|
||||
assert.equal(html.includes('Reveal traitor'), false);
|
||||
assert.equal(html.includes('No traitor'), false);
|
||||
assert.equal(html.includes('Make a bribe'), false);
|
||||
for (const restriction of ['paused', 'closed', 'autopilot', 'busy', 'automatic'] as const) {
|
||||
const blocked = structuredClone(own);
|
||||
if (restriction === 'autopilot') blocked.players.find(p => p.id === own.me)!.autopilot = 'Easy';
|
||||
else if (restriction === 'automatic') blocked.automaticContinuationPending = true;
|
||||
else if (restriction !== 'busy') blocked.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
|
||||
for (const choice of ['use', 'pass'] as const) assert.equal(select(blocked, choice, restriction === 'busy'), undefined);
|
||||
}
|
||||
const blocked = structuredClone(own);
|
||||
blocked.nexusHarkonnenBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
|
||||
assert.equal(select(blocked, 'use'), undefined);
|
||||
assert.ok(select(blocked, 'pass'));
|
||||
if (scenario.remote) {
|
||||
const native = viewGame(f.beforeNativeCounter, f.holder);
|
||||
assert.ok(native.response);
|
||||
assert.equal(native.nexusHarkonnenBetrayalReaction, null);
|
||||
assert.equal(select(native, 'use'), undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('every seat independently delegates and takes back control without changing declaration or custody', () => {
|
||||
const f = createHarkonnenNexusBetrayalFixture({ receiverCount: 2 });
|
||||
for (const seat of f.game.players) {
|
||||
const delegated = applyAction(f.game, seat.id, { type: 'setAutopilot', difficulty: 'Hard' });
|
||||
const own = viewGame(delegated, seat.id);
|
||||
assert.ok(table(own).includes('Take back control'));
|
||||
assert.equal(select(own, 'use'), undefined);
|
||||
assert.equal(select(own, 'pass'), undefined);
|
||||
const resumed = applyAction(delegated, seat.id, { type: 'setAutopilot', difficulty: null });
|
||||
assert.equal(resumed.players.find(p => p.id === seat.id)!.autopilot, undefined);
|
||||
assert.deepEqual(resumed.pendingNexusHarkonnenBetrayal, f.game.pendingNexusHarkonnenBetrayal);
|
||||
assert.deepEqual(resumed.traitorReserve, f.game.traitorReserve);
|
||||
assert.deepEqual(resumed.players.map(p => p.traitors), f.game.players.map(p => p.traitors));
|
||||
}
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,652 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
|
||||
import { shuffle, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
createHarkonnenNexusBetrayalFixture,
|
||||
nextHarkonnenBetrayalNativeStep,
|
||||
type HarkonnenBetrayalFixture,
|
||||
type HarkonnenBetrayalFixtureOptions as Options,
|
||||
} from './fixture-nexus-harkonnen-betrayal';
|
||||
|
||||
type SqlRow = Record<string, SQLOutputValue>;
|
||||
interface Fixture extends HarkonnenBetrayalFixture {
|
||||
sqlite: DatabaseSync;
|
||||
restart(): typeof Rooms;
|
||||
hooks: { beforeWrite?: () => Promise<void>; afterStatement?: (sql: string) => Promise<void> };
|
||||
code: string;
|
||||
tokens: string[];
|
||||
auths: SeatAuth[];
|
||||
save(game: Game): void;
|
||||
protectedRows: Record<string, SqlRow[]>;
|
||||
unrelatedCode: string;
|
||||
unrelatedToken: string;
|
||||
stock: string[];
|
||||
traitorStock: string[];
|
||||
setup: Game;
|
||||
initial: Game;
|
||||
}
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
|
||||
/** Compare durable records, including credentials, lifecycle and recovery resources,
|
||||
* rather than treating an attempted SQL update as a committed game action. */
|
||||
function records(sqlite: DatabaseSync, excludedRoom?: string) {
|
||||
const result: Record<string, SqlRow[]> = {};
|
||||
for (const row of sqlite.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()) {
|
||||
assert.equal(typeof row.name, 'string');
|
||||
const table = String(row.name).replaceAll('"', '""');
|
||||
const rows = sqlite.prepare(`SELECT * FROM "${table}"`).all();
|
||||
result[String(row.name)] = rows.filter(value => !(row.name === 'rooms' && value.code === excludedRoom));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function physical(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'native Treachery cards have one physical custodian');
|
||||
return ids;
|
||||
}
|
||||
function traitors(game: Game) {
|
||||
const ids = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => [...player.traitors, ...player.traitorChoices])].sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'each original physical Traitor Card has exactly one custodian');
|
||||
return ids;
|
||||
}
|
||||
function player(game: Game, id: string) {
|
||||
const value = game.players.find(player => player.id === id);
|
||||
assert.ok(value);
|
||||
return value;
|
||||
}
|
||||
function inventory(f: Fixture, game: Game) {
|
||||
assert.deepEqual(physical(game), f.stock);
|
||||
assert.equal(f.stock.length, 33);
|
||||
assert.deepEqual(traitors(game), f.traitorStock);
|
||||
assert.ok(game.nexusCards?.cards);
|
||||
validateNexusCards(game.nexusCards.cards, game.players);
|
||||
assert.deepEqual([...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard,
|
||||
...Object.values(game.nexusCards.cards.hands).filter(face => face !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
assert.deepEqual(records(f.sqlite, f.code), f.protectedRows);
|
||||
}
|
||||
async function authenticate(f: Fixture, id: string) {
|
||||
const index = f.auths.findIndex(auth => auth.playerId === id);
|
||||
assert.ok(index >= 0);
|
||||
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
|
||||
assert.equal(auth.playerId, id);
|
||||
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
|
||||
return auth;
|
||||
}
|
||||
async function act(f: Fixture, game: Game, id: string, action: Action) {
|
||||
await f.restart().act(f.code, await authenticate(f, id), game.version, action, clock);
|
||||
// The room API treats this native wake-up as read-only; its persisted worker
|
||||
// performs any genuine pending discard continuation through the normal CAS.
|
||||
if (action.type === 'advanceBots') await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
async function nativeCounters(f: Fixture, initial: Game) {
|
||||
let game = initial;
|
||||
for (let step = 0; game.response && step < 20; step++) {
|
||||
const responder = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(responder);
|
||||
game = await act(f, game, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
async function openGate(f: Fixture, beforeCall: Game) {
|
||||
let game = await act(f, beforeCall, f.provider, f.callAction);
|
||||
if (f.remote) {
|
||||
if (game.response) {
|
||||
assert.equal(game.response.kind, 'harkonnenTraitor');
|
||||
assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
} else {
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal!.source.nativeWindow, 'harkonnenTraitor');
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal!.source.nativeRequired.length, 0,
|
||||
'Only a real counter-free native call may reach Nexus automatically.');
|
||||
}
|
||||
}
|
||||
game = await nativeCounters(f, game);
|
||||
assert.ok(game.pendingNexusHarkonnenBetrayal);
|
||||
assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction!.event, f.event);
|
||||
return game;
|
||||
}
|
||||
async function fixture(t: test.TestContext, options: Options & { counterKind?: 'printed' | 'BG Worthless' } = {}): Promise<Fixture> {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Native Harkonnen recovery', 'harkonnen', options.advanced ?? true, []);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
// BG's original prediction keeps the CLI-produced setup genuinely undealt;
|
||||
// the fixture then consumes that native setup with its scoped deterministic RNG.
|
||||
const factions = options.counterKind === 'printed' ? ['atreides', 'emperor'] as const
|
||||
: ['atreides', 'emperor', 'beneGesserit'] as const;
|
||||
for (const faction of factions) {
|
||||
const joined = await store.rooms.joinRoom(code, faction, faction);
|
||||
assert.ok(joined.token);
|
||||
tokens.push(joined.token);
|
||||
}
|
||||
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
let admitted = await store.rooms.readRoom(code);
|
||||
for (const auth of auths) {
|
||||
await store.rooms.act(code, auth, admitted.version, { type: 'ready' }, clock);
|
||||
admitted = await store.rooms.readRoom(code);
|
||||
}
|
||||
// Main recovery scenarios continue the exact CLI-produced undealt setup.
|
||||
// Counter scenarios retain the authenticated fresh lobby so the native fixture
|
||||
// can conserve and order its first actual deal; no started custody is redealt.
|
||||
if (!options.counterKind) startPrototypeRoom(store.sqlite, code, admitted.version, 'harkonnen-betrayal');
|
||||
const setup = await store.rooms.readRoom(code);
|
||||
const native = createHarkonnenNexusBetrayalFixture({ advanced: options.advanced, remote: options.remote,
|
||||
receiverCount: options.receiverCount, face: options.face, initial: setup });
|
||||
assert.equal(native.beforeCall.code, code);
|
||||
assert.equal(native.beforeCall.host, admitted.host);
|
||||
assert.equal(native.beforeCall.advanced, admitted.advanced);
|
||||
assert.deepEqual(native.beforeCall.expansions, []);
|
||||
assert.deepEqual(native.beforeCall.playerPositions, admitted.playerPositions);
|
||||
assert.deepEqual(native.beforeCall.players.map(player => [player.id, player.name, player.faction]),
|
||||
admitted.players.map(player => [player.id, player.name, player.faction]));
|
||||
const save = (game: Game) => {
|
||||
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(game), game.version, code).changes, 1);
|
||||
};
|
||||
save(native.beforeCall);
|
||||
const unrelated = await store.rooms.createRoom('Unrelated preserved lobby', 'guild', false, []);
|
||||
const f: Fixture = { ...store, ...native, code, tokens, auths, save,
|
||||
protectedRows: records(store.sqlite, code), unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token,
|
||||
stock: physical(native.beforeCall), traitorStock: traitors(native.beforeCall), initial: native.beforeCall, setup };
|
||||
f.initial = await openGate(f, native.beforeCall);
|
||||
return f;
|
||||
}
|
||||
async function rejected(f: Fixture, game: Game, auth: SeatAuth, action: Action, version = game.version) {
|
||||
const before = records(f.sqlite);
|
||||
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
|
||||
assert.deepEqual(records(f.sqlite), before, 'rejection changes no durable game, credential or resource record');
|
||||
}
|
||||
async function restored(f: Fixture, game: Game) {
|
||||
const before = records(f.sqlite);
|
||||
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(game));
|
||||
for (const auth of f.auths) {
|
||||
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
||||
assert.deepEqual(plain(view), plain(viewGame(game, auth.playerId)));
|
||||
for (const key of ['pendingNexusHarkonnenBetrayal', 'nexusHarkonnenBetrayalCursor', 'nexusHarkonnenBetrayalHistory',
|
||||
'pendingNexusHarkonnenReplacement', 'nexusHarkonnenReplacementHistory', 'traitorReserve'])
|
||||
assert.equal(Object.hasOwn(view, key), false, `${key} stays private`);
|
||||
for (const rival of view.players.filter(player => player.id !== auth.playerId))
|
||||
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
|
||||
const reaction = view.nexusHarkonnenBetrayalReaction;
|
||||
if (reaction) {
|
||||
assert.deepEqual(Object.keys(reaction).sort(), ['event', 'provider', 'beneficiary', 'target', 'leader', 'identity', 'canPass', 'hasPassed', 'canUse', 'blocked'].sort());
|
||||
assert.equal(reaction.provider, f.provider);
|
||||
assert.equal(reaction.beneficiary, f.beneficiary);
|
||||
assert.equal(reaction.target, f.target);
|
||||
assert.equal(reaction.identity, f.identity, 'only the genuine public declaration is revealed');
|
||||
if (!f.required.includes(auth.playerId)) {
|
||||
assert.equal(reaction.canPass, false);
|
||||
assert.equal(reaction.canUse, false);
|
||||
assert.equal(reaction.blocked, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||
assert.deepEqual(records(f.sqlite), before, 'refresh/recovery never consumes a pending response or redraws a finished replacement');
|
||||
inventory(f, game);
|
||||
}
|
||||
async function closeGate(f: Fixture, initial: Game, used: boolean) {
|
||||
if (used) return act(f, initial, f.holder, { type: 'nexusHarkonnenBetrayalUse', event: f.event });
|
||||
let game = initial;
|
||||
for (const id of f.required) game = await act(f, game, id, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
||||
return game;
|
||||
}
|
||||
function settled(f: Fixture, game: Game, used: boolean) {
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal ?? null, null);
|
||||
assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
const history = game.nexusHarkonnenBetrayalHistory!;
|
||||
assert.equal(history.length, (f.initial.nexusHarkonnenBetrayalHistory?.length ?? 0) + 1);
|
||||
const receipt = history.at(-1)!;
|
||||
assert.deepEqual(receipt.source, f.initial.pendingNexusHarkonnenBetrayal!.source);
|
||||
assert.equal(receipt.outcome, used ? 'use' : 'pass');
|
||||
assert.equal(receipt.holder, used ? f.holder : null);
|
||||
assert.equal(game.nexusHarkonnenBetrayalCursor!.sequence, f.initial.nexusHarkonnenBetrayalCursor!.sequence + 1);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder], used ? null : f.initial.nexusCards!.cards!.hands[f.holder]);
|
||||
assert.deepEqual(game.nexusCards!.cards!.discard, [...f.initial.nexusCards!.cards!.discard, ...(used ? ['harkonnen'] : [])]);
|
||||
assert.deepEqual(player(game, f.provider).traitors, used
|
||||
? player(f.initial, f.provider).traitors.filter(identity => identity !== f.identity)
|
||||
: player(f.initial, f.provider).traitors);
|
||||
if (used) {
|
||||
assert.deepEqual([...(game.traitorReserve ?? [])].sort(), [...(f.initial.traitorReserve ?? []), f.identity].sort());
|
||||
assert.equal(player(game, f.provider).revealedTraitors?.includes(f.identity) ?? false, false);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement!.status, 'due');
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement!.identity, f.identity);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement!.provider, f.provider);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement!.turn, f.initial.turn);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement!.drawn, null);
|
||||
assert.equal(game.battle?.traitorCalls[f.provider], false, 'the original call is canceled, not replayed');
|
||||
} else {
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement ?? null, null);
|
||||
assert.deepEqual(game.traitorReserve, f.initial.traitorReserve);
|
||||
}
|
||||
inventory(f, game);
|
||||
}
|
||||
async function mentat(f: Fixture, initial: Game, raceRecovery = false) {
|
||||
let game = initial;
|
||||
for (let step = 0; game.phase !== 8 && step < 80; step++) {
|
||||
const next = nextHarkonnenBetrayalNativeStep(game);
|
||||
const before = game;
|
||||
if (raceRecovery) {
|
||||
await Promise.all([act(f, before, next.actor, next.action),
|
||||
f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||
game = await f.restart().readRoom(f.code);
|
||||
} else game = await act(f, before, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.phase, 8, 'actual battle resolution, Collection and native readiness reach Mentat');
|
||||
assert.equal(game.turn, initial.turn);
|
||||
assert.equal(game.battle, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const remote of [false, true]) for (const used of [false, true]) {
|
||||
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${remote ? 'allied' : 'personal'} Harkonnen ${used ? 'Use' : 'all-pass'} continues the original battle and actual Mentat once`, async t => {
|
||||
const f = await fixture(t, { advanced, remote });
|
||||
await restored(f, f.initial);
|
||||
const done = await closeGate(f, f.initial, used);
|
||||
settled(f, done, used);
|
||||
await restored(f, done);
|
||||
const expectedDraw = done.traitorReserve?.[0];
|
||||
const previousHeld = [...player(done, f.provider).traitors];
|
||||
const pause = await mentat(f, done, true);
|
||||
assert.equal(pause.pendingNexusHarkonnenReplacement ?? null, null);
|
||||
if (used) {
|
||||
const drawn = pause.nexusHarkonnenReplacementHistory!.at(-1)!;
|
||||
assert.equal(drawn.status, 'drawn');
|
||||
assert.equal(drawn.event, f.event);
|
||||
assert.equal(drawn.drawn, expectedDraw);
|
||||
assert.deepEqual(player(pause, f.provider).traitors, [...previousHeld, expectedDraw]);
|
||||
assert.deepEqual(pause.traitorReserve, done.traitorReserve!.slice(1));
|
||||
assert.equal(pause.nexusHarkonnenReplacementHistory!.length, (done.nexusHarkonnenReplacementHistory?.length ?? 0) + 1);
|
||||
} else {
|
||||
assert.deepEqual(pause.nexusHarkonnenReplacementHistory, done.nexusHarkonnenReplacementHistory);
|
||||
assert.deepEqual(player(pause, f.provider).traitors, previousHeld);
|
||||
}
|
||||
await restored(f, pause);
|
||||
for (const type of ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const)
|
||||
await rejected(f, pause, await authenticate(f, f.holder), { type, event: f.event });
|
||||
});
|
||||
}
|
||||
|
||||
void test('SQLite lost successful Use response restores the exact committed shuffle and cannot spend or schedule twice', async t => {
|
||||
const f = await fixture(t, { remote: true });
|
||||
let lost = false;
|
||||
f.hooks.afterStatement = async sql => {
|
||||
if (sql.startsWith('UPDATE rooms SET state') && !lost) { lost = true; throw new Error('lost response after committed CAS'); }
|
||||
};
|
||||
try {
|
||||
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), f.initial.version,
|
||||
{ type: 'nexusHarkonnenBetrayalUse', event: f.event }, clock), /lost response/);
|
||||
} finally { delete f.hooks.afterStatement; }
|
||||
const committed = await f.restart().readRoom(f.code);
|
||||
assert.equal(committed.version, f.initial.version + 1);
|
||||
settled(f, committed, true);
|
||||
await restored(f, committed);
|
||||
await rejected(f, committed, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event }, f.initial.version);
|
||||
await rejected(f, committed, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event });
|
||||
});
|
||||
|
||||
void test('SQLite actor credentials, revoked sessions and event-only selectors fence private state without durable changes', async t => {
|
||||
const f = await fixture(t);
|
||||
const holder = await authenticate(f, f.holder);
|
||||
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
|
||||
const use: Action = { type: 'nexusHarkonnenBetrayalUse', event: f.event };
|
||||
const before = records(f.sqlite);
|
||||
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
|
||||
await assert.rejects(f.restart().readSeatView(f.code, foreign));
|
||||
await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.holder, tokenHash: foreign.tokenHash }));
|
||||
assert.deepEqual(records(f.sqlite), before);
|
||||
for (const auth of [foreign, { playerId: f.holder, tokenHash: foreign.tokenHash }, await authenticate(f, f.provider)])
|
||||
await rejected(f, f.initial, auth, use);
|
||||
await rejected(f, f.initial, holder, use, f.initial.version - 1);
|
||||
for (const extra of [{ actor: f.provider }, { player: f.provider }, { provider: f.provider }, { holder: f.holder },
|
||||
{ identity: f.identity }, { leader: f.identity }, { target: f.target }, { card: 'harkonnen' }, { event: `${f.event}:stale` }, { replacement: f.identity }])
|
||||
for (const type of ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const)
|
||||
await rejected(f, f.initial, holder, { ...use, type, ...extra } as Action);
|
||||
for (const action of [f.callAction, { type: 'passResponse' }, { type: 'ready' }, { type: 'traitorCall', call: false }, { type: 'nexusCard', choice: 'replace' }])
|
||||
await rejected(f, f.initial, await authenticate(f, f.provider), action as Action);
|
||||
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
|
||||
f.protectedRows = records(f.sqlite, f.code);
|
||||
await assert.rejects(f.restart().readSeatView(f.code, holder));
|
||||
await rejected(f, f.initial, holder, use);
|
||||
});
|
||||
|
||||
void test('SQLite neutral rival acknowledgments are identical for genuine relevant and irrelevant natural Nexus draws', async t => {
|
||||
const f = await fixture(t);
|
||||
const irrelevantFixture = createHarkonnenNexusBetrayalFixture({ initial: f.setup, face: 'richese' });
|
||||
const other: Fixture = { ...f, ...irrelevantFixture };
|
||||
f.save(irrelevantFixture.beforeCall);
|
||||
const irrelevant = await openGate(other, irrelevantFixture.beforeCall);
|
||||
other.initial = irrelevant;
|
||||
for (const auth of f.auths) {
|
||||
const original = viewGame(f.initial, auth.playerId);
|
||||
const changed = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
||||
if (auth.playerId !== f.holder) {
|
||||
const originalReaction = original.nexusHarkonnenBetrayalReaction!;
|
||||
const changedReaction = changed.nexusHarkonnenBetrayalReaction!;
|
||||
assert.deepEqual({ ...changedReaction, event: null }, { ...originalReaction, event: null });
|
||||
assert.deepEqual(changed.players.map(player => [player.id, player.revealedTraitors]),
|
||||
original.players.map(player => [player.id, player.revealedTraitors]));
|
||||
assert.deepEqual(changed.nexusCards, original.nexusCards);
|
||||
}
|
||||
else {
|
||||
assert.equal(changed.nexusHarkonnenBetrayalReaction!.canPass, true);
|
||||
assert.equal(changed.nexusHarkonnenBetrayalReaction!.canUse, false);
|
||||
}
|
||||
}
|
||||
await restored(other, irrelevant);
|
||||
await rejected(other, irrelevant, await authenticate(other, other.holder), { type: 'nexusHarkonnenBetrayalUse', event: other.event });
|
||||
const done = await closeGate(other, irrelevant, false);
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
|
||||
assert.deepEqual(done.nexusCards!.cards!.discard, irrelevant.nexusCards!.cards!.discard);
|
||||
inventory(f, done);
|
||||
});
|
||||
|
||||
void test('SQLite two public receivers restore partial Pass, retain private faces and reject duplicate/stale acknowledgement', async t => {
|
||||
const f = await fixture(t, { receiverCount: 2 });
|
||||
assert.equal(f.required.length, 2);
|
||||
const firstId = f.required.find(id => id !== f.holder)!;
|
||||
const first = await act(f, f.initial, firstId, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
||||
assert.deepEqual(first.pendingNexusHarkonnenBetrayal!.passed, [firstId]);
|
||||
assert.equal(viewGame(first, firstId).nexusHarkonnenBetrayalReaction!.hasPassed, true);
|
||||
assert.equal(viewGame(first, f.holder).active, f.holder);
|
||||
assert.deepEqual(first.nexusCards!.cards, f.initial.nexusCards!.cards);
|
||||
assert.deepEqual(first.nexusHarkonnenBetrayalHistory, f.initial.nexusHarkonnenBetrayalHistory);
|
||||
await restored(f, first);
|
||||
await rejected(f, first, await authenticate(f, firstId), { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
||||
await rejected(f, first, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event }, f.initial.version);
|
||||
const done = await closeGate(f, first, true);
|
||||
settled(f, done, true);
|
||||
});
|
||||
|
||||
for (const race of ['use/pass', 'use/use', 'two passes'] as const) {
|
||||
void test(`SQLite concurrent ${race} commits one source transition and restores the losing actor's fresh continuation`, async t => {
|
||||
const f = await fixture(t, { receiverCount: race === 'two passes' ? 2 : 1 });
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
||||
const ids = race === 'two passes' ? f.required : [f.holder, f.holder];
|
||||
const auths = await Promise.all(ids.map(id => authenticate(f, id)));
|
||||
const types = race === 'two passes' ? ['nexusHarkonnenBetrayalPass', 'nexusHarkonnenBetrayalPass'] as const
|
||||
: race === 'use/use' ? ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalUse'] as const
|
||||
: ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const;
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled(auths.map((auth, index) => f.restart().act(f.code, auth, f.initial.version,
|
||||
{ type: types[index], event: f.event }, clock).finally(release)));
|
||||
} finally { delete f.hooks.beforeWrite; }
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||
let done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, f.initial.version + 1);
|
||||
if (race === 'two passes') {
|
||||
const winner = results.findIndex(result => result.status === 'fulfilled');
|
||||
assert.deepEqual(done.pendingNexusHarkonnenBetrayal!.passed, [ids[winner]]);
|
||||
await restored(f, done);
|
||||
await rejected(f, done, auths[winner], { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
||||
done = await act(f, done, ids[1 - winner], { type: 'nexusHarkonnenBetrayalPass', event: f.event });
|
||||
settled(f, done, false);
|
||||
} else settled(f, done, race === 'use/use' || results[0].status === 'fulfilled');
|
||||
await restored(f, done);
|
||||
});
|
||||
}
|
||||
|
||||
async function corruptRejected(f: Fixture, corrupt: Game, name: string) {
|
||||
f.save(corrupt);
|
||||
const before = records(f.sqlite);
|
||||
const original = plain(corrupt);
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
|
||||
for (const type of ['nexusHarkonnenBetrayalPass', 'nexusHarkonnenBetrayalUse'] as const)
|
||||
await rejected(f, corrupt, await authenticate(f, f.holder), { type, event: f.event });
|
||||
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||
assert.deepEqual(records(f.sqlite), before, `${name}: read/action/normalize never repairs or consumes corrupt saves`);
|
||||
assert.deepEqual(corrupt, original);
|
||||
}
|
||||
|
||||
for (const remote of [false, true]) {
|
||||
void test(`SQLite ${remote ? 'allied' : 'personal'} committed source and physical custody corruption is immutable across authenticated read/action/recovery`, async t => {
|
||||
const f = await fixture(t, remote ? { remote: true, counterKind: 'printed' } : { remote: false });
|
||||
if (remote) assert.ok(f.initial.pendingNexusHarkonnenBetrayal!.source.nativePassed.length,
|
||||
'The lost-pass mutation requires an actual completed native counter window.');
|
||||
const alterations: { name: string; change: (game: Game) => void }[] = [
|
||||
{ name: 'missing gate', change: game => { delete game.pendingNexusHarkonnenBetrayal; } },
|
||||
{ name: 'missing cursor', change: game => { delete game.nexusHarkonnenBetrayalCursor; } },
|
||||
{ name: 'missing history', change: game => { delete game.nexusHarkonnenBetrayalHistory; } },
|
||||
{ name: 'cursor sequence', change: game => { game.nexusHarkonnenBetrayalCursor!.sequence++; } },
|
||||
{ name: 'source parent', change: game => { game.pendingNexusHarkonnenBetrayal!.source.parent += ':forged'; } },
|
||||
{ name: 'native counter context', change: game => { game.pendingNexusHarkonnenBetrayal!.source.nativeContext += ':forged'; } },
|
||||
{ name: 'native window', change: game => { game.pendingNexusHarkonnenBetrayal!.source.nativeWindow = remote ? 'direct' : 'harkonnenTraitor'; } },
|
||||
{ name: 'source provider', change: game => { game.pendingNexusHarkonnenBetrayal!.source.provider = f.holder; } },
|
||||
{ name: 'matched identity', change: game => { game.pendingNexusHarkonnenBetrayal!.source.declaration.identity = game.traitorReserve![0]; } },
|
||||
{ name: 'battle event', change: game => { game.battle!.event += ':forged'; } },
|
||||
{ name: 'beneficiary role', change: game => { game.pendingNexusHarkonnenBetrayal!.source.declaration.beneficiary = f.holder; } },
|
||||
{ name: 'required membership', change: game => { game.pendingNexusHarkonnenBetrayal!.required = [f.provider]; } },
|
||||
{ name: 'invented pass', change: game => { game.pendingNexusHarkonnenBetrayal!.passed = [f.provider]; } },
|
||||
{ name: 'public declaration', change: game => { game.battle!.traitorDeclarations![f.provider].signature += ':forged'; } },
|
||||
{ name: 'missing called physical traitor', change: game => {
|
||||
player(game, f.provider).traitors = player(game, f.provider).traitors.filter(identity => identity !== f.identity);
|
||||
game.traitorReserve!.push(f.identity);
|
||||
} },
|
||||
{ name: 'duplicate Traitor identity', change: game => { game.traitorReserve!.push(f.identity); } },
|
||||
{ name: 'missing Traitor identity', change: game => { game.traitorReserve!.pop(); } },
|
||||
{ name: 'duplicate Nexus', change: game => { game.nexusCards!.cards!.deck.push('harkonnen'); } },
|
||||
{ name: 'mutated committed Nexus face', change: game => {
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('richese');
|
||||
assert.ok(index >= 0);
|
||||
cards.hands[f.holder] = 'richese';
|
||||
cards.deck[index] = 'harkonnen';
|
||||
} },
|
||||
{ name: 'missing base Treachery', change: game => { game.deck.pop(); } },
|
||||
{ name: 'canonical Treachery face', change: game => { game.deck[0].name += ' forged'; } },
|
||||
{ name: 'lost preview boundary', change: game => { delete game.nexusHarkonnenBetrayalPreview; } },
|
||||
{ name: 'native faction changed', change: game => { player(game, f.provider).faction = 'guild'; } },
|
||||
{ name: 'foreign expansion', change: game => { game.expansions.push('ix'); } },
|
||||
{ name: 'wrong phase', change: game => { game.phase++; } },
|
||||
{ name: 'wrong turn', change: game => { game.turn++; } },
|
||||
...(remote ? [{ name: 'lost original counter passes', change: (game: Game) => {
|
||||
game.pendingNexusHarkonnenBetrayal!.source.nativePassed = [];
|
||||
} }] : []),
|
||||
];
|
||||
for (const { name, change } of alterations) {
|
||||
const corrupt = structuredClone(f.initial);
|
||||
change(corrupt);
|
||||
await corruptRejected(f, corrupt, name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('SQLite exact successful Use obligation restores before Mentat, privately draws once and rejects corrupted late provenance', async t => {
|
||||
const f = await fixture(t, { remote: true });
|
||||
const due = await closeGate(f, f.initial, true);
|
||||
await restored(f, due);
|
||||
const pending = due.pendingNexusHarkonnenReplacement!;
|
||||
const dueAlterations: { name: string; change: (game: Game) => void }[] = [
|
||||
{ name: 'missing obligation', change: game => { game.pendingNexusHarkonnenReplacement = null; } },
|
||||
{ name: 'replacement wrong provider', change: game => { game.pendingNexusHarkonnenReplacement!.provider = f.holder; } },
|
||||
{ name: 'replacement wrong turn', change: game => { game.pendingNexusHarkonnenReplacement!.turn++; } },
|
||||
{ name: 'replacement wrong identity', change: game => { game.pendingNexusHarkonnenReplacement!.identity = game.traitorReserve!.find(identity => identity !== f.identity)!; } },
|
||||
{ name: 'replacement receipt link', change: game => { game.pendingNexusHarkonnenReplacement!.receipt += ':forged'; } },
|
||||
{ name: 'premature replacement', change: game => {
|
||||
game.pendingNexusHarkonnenReplacement!.status = 'drawn';
|
||||
game.pendingNexusHarkonnenReplacement!.drawn = game.traitorReserve![0];
|
||||
} },
|
||||
{ name: 'closed history link', change: game => { game.nexusHarkonnenBetrayalHistory!.at(-1)!.previous += ':forged'; } },
|
||||
{ name: 'closed declaration source', change: game => { game.nexusHarkonnenBetrayalHistory!.at(-1)!.source.nativeContext += ':forged'; } },
|
||||
];
|
||||
for (const { name, change } of dueAlterations) {
|
||||
const corrupt = structuredClone(due);
|
||||
change(corrupt);
|
||||
await corruptRejected(f, corrupt, name);
|
||||
}
|
||||
f.save(due);
|
||||
let game = due;
|
||||
for (let count = 0; game.phase !== 7 && count < 80; count++) {
|
||||
const step = nextHarkonnenBetrayalNativeStep(game);
|
||||
game = await act(f, game, step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.phase, 7, 'the original battle reaches actual Collection without changing the due obligation');
|
||||
await restored(f, game);
|
||||
assert.deepEqual(game.pendingNexusHarkonnenReplacement, pending);
|
||||
const pause = await mentat(f, game, true);
|
||||
assert.equal(pause.pendingNexusHarkonnenReplacement, null);
|
||||
const drawn = pause.nexusHarkonnenReplacementHistory!.at(-1)!;
|
||||
assert.equal(drawn.receipt, pending.receipt);
|
||||
assert.equal(drawn.drawn, game.traitorReserve![0]);
|
||||
const providerView = await f.restart().readSeatView(f.code, await authenticate(f, f.provider));
|
||||
assert.deepEqual(providerView.players.find(player => player.id === f.provider)!.traitors, player(pause, f.provider).traitors);
|
||||
for (const auth of f.auths.filter(auth => auth.playerId !== f.provider)) {
|
||||
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
||||
assert.equal(Object.hasOwn(view.players.find(player => player.id === f.provider)!, 'traitors'), false);
|
||||
assert.equal(Object.hasOwn(view, 'nexusHarkonnenReplacementHistory'), false);
|
||||
}
|
||||
await restored(f, pause);
|
||||
for (const change of [
|
||||
(game: Game) => { game.nexusHarkonnenReplacementHistory = []; },
|
||||
(game: Game) => { game.nexusHarkonnenReplacementHistory!.push(structuredClone(drawn)); },
|
||||
(game: Game) => { game.nexusHarkonnenReplacementHistory!.at(-1)!.drawn = 'unknown-physical-identity'; },
|
||||
(game: Game) => { game.nexusHarkonnenReplacementHistory!.at(-1)!.receipt += ':forged'; },
|
||||
(game: Game) => { game.pendingNexusHarkonnenReplacement = structuredClone(pending); },
|
||||
(game: Game) => { game.nexusHarkonnenBetrayalHistory = []; },
|
||||
(game: Game) => { game.nexusHarkonnenBetrayalCursor!.signature += ':forged'; },
|
||||
]) {
|
||||
const corrupt = structuredClone(pause);
|
||||
change(corrupt);
|
||||
await corruptRejected(f, corrupt, 'completed replacement provenance');
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite Use shuffles the exact retired physical identity and permits its genuine same-card Mentat redraw once', async t => {
|
||||
const f = await fixture(t);
|
||||
const sourceReserve = [...f.initial.traitorReserve!, f.identity];
|
||||
let quoteIndex = 0;
|
||||
const expected = shuffle(sourceReserve, () => quoteIndex++ === 0 ? 0 : 0xffffffff / 4294967296);
|
||||
assert.equal(expected[0], f.identity, 'deterministic real shuffle places the retired identity at the actual deck top');
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'getRandomValues');
|
||||
let randomIndex = 0;
|
||||
Object.defineProperty(globalThis.crypto, 'getRandomValues', {
|
||||
configurable: true,
|
||||
value: <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array instanceof Uint32Array);
|
||||
for (let index = 0; index < array.length; index++) array[index] = randomIndex++ === 0 ? 0 : 0xffffffff;
|
||||
return array;
|
||||
},
|
||||
});
|
||||
let due: Game;
|
||||
try {
|
||||
due = await closeGate(f, f.initial, true);
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(globalThis.crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(globalThis.crypto, 'getRandomValues');
|
||||
}
|
||||
assert.deepEqual(due.traitorReserve, expected, 'Use performs the native shuffle on reserve plus exactly the called physical card');
|
||||
assert.deepEqual(player(due, f.provider).traitors, player(f.initial, f.provider).traitors.filter(identity => identity !== f.identity));
|
||||
await restored(f, due);
|
||||
const pause = await mentat(f, due, true);
|
||||
assert.equal(pause.nexusHarkonnenReplacementHistory!.at(-1)!.drawn, f.identity);
|
||||
assert.equal(player(pause, f.provider).traitors.filter(identity => identity === f.identity).length, 1);
|
||||
assert.deepEqual(pause.traitorReserve, expected.slice(1));
|
||||
assert.equal(player(pause, f.provider).revealedTraitors?.includes(f.identity) ?? false, false,
|
||||
'a private replacement does not turn the historically declared card into current public ownership');
|
||||
await restored(f, pause);
|
||||
});
|
||||
|
||||
void test('SQLite lost final Collection response restores the original actual Mentat draw without replaying battle or phase effects', async t => {
|
||||
const f = await fixture(t, { remote: true });
|
||||
const due = await closeGate(f, f.initial, true);
|
||||
let lost = false;
|
||||
f.hooks.afterStatement = async sql => {
|
||||
if (!sql.startsWith('UPDATE rooms SET state')) return;
|
||||
const saved = await f.restart().readRoom(f.code);
|
||||
if (saved.phase === 8 && !lost) { lost = true; throw new Error('lost final Collection response'); }
|
||||
};
|
||||
try {
|
||||
await assert.rejects(mentat(f, due), /lost final Collection response/);
|
||||
} finally { delete f.hooks.afterStatement; }
|
||||
assert.equal(lost, true);
|
||||
const pause = await f.restart().readRoom(f.code);
|
||||
assert.equal(pause.phase, 8);
|
||||
assert.equal(pause.turn, due.turn);
|
||||
assert.equal(pause.battle, null);
|
||||
assert.equal(pause.pendingNexusHarkonnenReplacement, null);
|
||||
assert.equal(pause.nexusHarkonnenReplacementHistory!.at(-1)!.drawn, due.traitorReserve![0]);
|
||||
assert.deepEqual(player(pause, f.provider).traitors, [...player(due, f.provider).traitors, due.traitorReserve![0]]);
|
||||
assert.deepEqual(pause.traitorReserve, due.traitorReserve!.slice(1));
|
||||
await restored(f, pause);
|
||||
});
|
||||
|
||||
for (const counterKind of ['printed', 'BG Worthless'] as const) {
|
||||
void test(`SQLite actual allied Harkonnen ${counterKind} counter preempts the Nexus acknowledgement and preserves its face`, async t => {
|
||||
const f = await fixture(t, { remote: true, advanced: true, counterKind });
|
||||
f.save(f.beforeCall);
|
||||
const declared = await act(f, f.beforeCall, f.provider, f.callAction);
|
||||
assert.ok(declared.response);
|
||||
assert.equal(viewGame(declared, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
const counter = counterKind === 'printed'
|
||||
? declared.players.find(player => player.id !== f.provider && player.hand.some(card => card.effect === 'karama'))
|
||||
: declared.players.find(player => player.faction === 'beneGesserit');
|
||||
assert.ok(counter);
|
||||
const card = counter.hand.find(card => counterKind === 'printed' ? card.effect === 'karama' : card.kind === 'worthless');
|
||||
assert.ok(card, 'the conserved actual native deal supplies the counter card');
|
||||
const canceled = await act(f, declared, counter.id, { type: 'card', mode: 'cancel', card: card.id });
|
||||
const done = await nativeCounters(f, canceled);
|
||||
assert.equal(done.pendingNexusHarkonnenBetrayal ?? null, null);
|
||||
assert.equal(done.pendingNexusHarkonnenReplacement ?? null, null);
|
||||
assert.equal(viewGame(done, f.holder).nexusHarkonnenBetrayalReaction, null);
|
||||
assert.deepEqual(done.nexusHarkonnenBetrayalHistory, f.beforeCall.nexusHarkonnenBetrayalHistory);
|
||||
assert.deepEqual(done.nexusHarkonnenBetrayalCursor, f.beforeCall.nexusHarkonnenBetrayalCursor);
|
||||
assert.deepEqual(done.nexusCards!.cards, f.beforeCall.nexusCards!.cards);
|
||||
assert.deepEqual(player(done, f.provider).traitors, player(f.beforeCall, f.provider).traitors);
|
||||
assert.equal(done.discard.filter(value => value.id === card.id).length, 1);
|
||||
await restored(f, done);
|
||||
});
|
||||
}
|
||||
|
||||
void test('SQLite credential revocation at the final Use fence cannot commit a source, shuffle or replacement', async t => {
|
||||
const f = await fixture(t);
|
||||
const auth = await authenticate(f, f.holder);
|
||||
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
|
||||
let revoked = false;
|
||||
f.hooks.beforeWrite = async () => {
|
||||
assert.equal(revoked, false);
|
||||
revoked = true;
|
||||
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
|
||||
f.protectedRows = records(f.sqlite, f.code);
|
||||
};
|
||||
try {
|
||||
await assert.rejects(f.restart().act(f.code, auth, f.initial.version, { type: 'nexusHarkonnenBetrayalUse', event: f.event }, clock));
|
||||
} finally { delete f.hooks.beforeWrite; }
|
||||
assert.equal(revoked, true);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
|
||||
inventory(f, await f.restart().readRoom(f.code));
|
||||
await assert.rejects(f.restart().readSeatView(f.code, auth));
|
||||
});
|
||||
|
||||
void test('SQLite every credential keeps independent own autopilot control while the original Harkonnen source remains pending', async t => {
|
||||
const f = await fixture(t, { receiverCount: 2 });
|
||||
let game = f.initial;
|
||||
for (const auth of f.auths) {
|
||||
const previous = game;
|
||||
game = await act(f, game, auth.playerId, { type: 'setAutopilot', difficulty: 'Easy' });
|
||||
assert.equal(player(game, auth.playerId).autopilot, 'Easy');
|
||||
assert.deepEqual(game.pendingNexusHarkonnenBetrayal, previous.pendingNexusHarkonnenBetrayal);
|
||||
assert.deepEqual(game.nexusCards!.cards, previous.nexusCards!.cards);
|
||||
assert.deepEqual(game.traitorReserve, previous.traitorReserve);
|
||||
game = await act(f, game, auth.playerId, { type: 'setAutopilot', difficulty: null });
|
||||
assert.equal(player(game, auth.playerId).autopilot ?? null, null);
|
||||
assert.deepEqual(game.pendingNexusHarkonnenBetrayal, f.initial.pendingNexusHarkonnenBetrayal);
|
||||
}
|
||||
await restored(f, game);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {applyAction,createGame,initializeHarkonnenNexusBetrayalGameForAudit,joinGame,newPlayer,normalizeAutomaticGame,viewGame,type Action,type Game} from '../game/engine';
|
||||
import {baseDeck} from '../game/cards';
|
||||
import {traitorDeck} from '../game/traitors';
|
||||
import {validateNexusTraitorSnapshot} from '../game/nexus-traitor-exchange';
|
||||
import {createHarkonnenNexusBetrayalFixture,advanceHarkonnenBetrayalToMentat,type HarkonnenBetrayalFixture} from './fixture-nexus-harkonnen-betrayal';
|
||||
|
||||
function stock(game:Game):void {
|
||||
validateNexusTraitorSnapshot({reserve:game.traitorReserve!,players:game.players.map(({id,faction,traitors})=>({id,faction,traitors}))},traitorDeck(game.players));
|
||||
assert.deepEqual([...game.deck,...game.discard,...game.players.flatMap(p=>p.hand),...(game.auction?.cards??[])].map(c=>c.id).sort(),baseDeck().map(c=>c.id).sort());
|
||||
}
|
||||
function use(f:HarkonnenBetrayalFixture):Game {return applyAction(f.game,f.holder,{type:'nexusHarkonnenBetrayalUse',event:f.event});}
|
||||
function reject(game:Game,actor:string,action:Action):void {
|
||||
const before=JSON.stringify(game);assert.throws(()=>applyAction(game,actor,action));assert.equal(JSON.stringify(game),before);
|
||||
}
|
||||
for(const advanced of [false,true]) for(const remote of [false,true]) {
|
||||
void test(`${advanced?'Advanced':'Basic'} ${remote?'allied remote':'personal'} real call cancellation retires exact identity and native Mentat privately draws once`,()=>{
|
||||
const f=createHarkonnenNexusBetrayalFixture({advanced,remote});
|
||||
assert.equal(f.beforeCall.battle!.traitorCalls[f.provider],undefined);
|
||||
assert.equal(f.beforeNativeCounter.response?.kind,remote?'harkonnenTraitor':undefined);
|
||||
assert.equal(f.game.battle!.traitorCalls[f.provider],true);
|
||||
const reaction=viewGame(f.game,f.holder).nexusHarkonnenBetrayalReaction!;
|
||||
assert.equal(reaction.identity,f.identity);assert.equal(reaction.beneficiary,f.beneficiary);assert.equal(reaction.target,f.target);assert.equal(reaction.canUse,true);
|
||||
assert.ok(f.game.players.find(p=>p.id===f.provider)!.revealedTraitors!.includes(f.identity));
|
||||
const before=JSON.stringify(f.game),spent=use(f);assert.equal(JSON.stringify(f.game),before);
|
||||
assert.equal(spent.nexusCards!.cards!.hands[f.holder],null);
|
||||
assert.equal(spent.nexusCards!.cards!.discard.filter(c=>c==='harkonnen').length,1);
|
||||
assert.equal(spent.players.find(p=>p.id===f.provider)!.traitors.includes(f.identity),false);
|
||||
assert.equal(spent.players.find(p=>p.id===f.provider)!.revealedTraitors!.includes(f.identity),false);
|
||||
assert.equal(spent.traitorReserve!.filter(c=>c===f.identity).length,1);
|
||||
assert.equal(spent.battle!.traitorCalls[f.provider],false);
|
||||
assert.equal(spent.battle!.traitorDeclarations![f.provider].identity,f.identity);
|
||||
assert.equal(spent.pendingNexusHarkonnenReplacement!.identity,f.identity);
|
||||
assert.equal(spent.pendingNexusHarkonnenReplacement!.status,'due');
|
||||
stock(spent);
|
||||
const top=spent.traitorReserve![0],mentat=advanceHarkonnenBetrayalToMentat(spent);
|
||||
assert.equal(mentat.nexusHarkonnenReplacementHistory!.length,1);
|
||||
assert.equal(mentat.nexusHarkonnenReplacementHistory![0].drawn,top);
|
||||
assert.equal(mentat.players.find(p=>p.id===f.provider)!.traitors.length,4);
|
||||
assert.equal(mentat.players.find(p=>p.id===f.provider)!.traitors.includes(top),true);
|
||||
assert.equal(mentat.pendingNexusHarkonnenReplacement,null);stock(mentat);
|
||||
const stable=JSON.stringify(mentat);
|
||||
for(const p of mentat.players) viewGame(mentat,p.id);
|
||||
assert.equal(JSON.stringify(mentat),stable);
|
||||
assert.deepEqual(normalizeAutomaticGame(mentat),mentat);
|
||||
assert.equal(mentat.lastBattleContext!.result,'normal');
|
||||
reject(mentat,f.holder,{type:'nexusHarkonnenBetrayalUse',event:f.event});
|
||||
});
|
||||
void test(`${advanced?'Advanced':'Basic'} ${remote?'remote':'personal'} all-pass retains both physical cards and permits original traitor outcome once`,()=>{
|
||||
const f=createHarkonnenNexusBetrayalFixture({advanced,remote});
|
||||
let game=f.game;
|
||||
for(const id of f.required) game=applyAction(game,id,{type:'nexusHarkonnenBetrayalPass',event:f.event});
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal,null);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement,null);
|
||||
assert.ok(game.players.find(p=>p.id===f.provider)!.traitors.includes(f.identity));
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder],'harkonnen');
|
||||
game=advanceHarkonnenBetrayalToMentat(game);
|
||||
assert.equal(game.lastBattleContext!.result,'traitor');
|
||||
assert.equal(game.nexusHarkonnenReplacementHistory!.length,0);
|
||||
assert.equal(game.players.find(p=>p.id===f.provider)!.revealedTraitors!.filter(card=>card===f.identity).length,1);
|
||||
reject(game,f.provider,f.callAction);stock(game);
|
||||
});
|
||||
}
|
||||
void test('Wrong-face holder receives the same public acknowledgement but cannot spend another face',()=>{
|
||||
const f=createHarkonnenNexusBetrayalFixture({face:'richese',remote:true}),view=viewGame(f.game,f.holder);
|
||||
assert.equal(view.nexusHarkonnenBetrayalReaction!.canPass,true);
|
||||
assert.equal(view.nexusHarkonnenBetrayalReaction!.canUse,false);
|
||||
assert.equal(view.nexusHarkonnenBetrayalReaction!.blocked,null);
|
||||
reject(f.game,f.holder,{type:'nexusHarkonnenBetrayalUse',event:f.event});
|
||||
const passed=applyAction(f.game,f.holder,{type:'nexusHarkonnenBetrayalPass',event:f.event});
|
||||
assert.equal(passed.nexusCards!.cards!.hands[f.holder],'richese');
|
||||
assert.equal(passed.nexusHarkonnenBetrayalHistory![0].outcome,'pass');
|
||||
});
|
||||
void test('Partial acknowledgement survives refresh; ordinary votes lock while independent controls remain legal',()=>{
|
||||
const f=createHarkonnenNexusBetrayalFixture({receiverCount:2,remote:true});
|
||||
assert.equal(f.required.length,2);assert.equal(viewGame(f.game,f.holder).active,null);
|
||||
const other=f.required.find(id=>id!==f.holder)!;
|
||||
let game=applyAction(f.game,other,{type:'nexusHarkonnenBetrayalPass',event:f.event});
|
||||
game=normalizeAutomaticGame(structuredClone(game));
|
||||
assert.equal(viewGame(game,f.holder).active,f.holder);
|
||||
assert.equal(viewGame(game,other).nexusHarkonnenBetrayalReaction!.hasPassed,true);
|
||||
reject(game,other,{type:'nexusHarkonnenBetrayalPass',event:f.event});
|
||||
reject(game,f.target,{type:'traitorCall',call:false});
|
||||
const source=JSON.stringify(game.pendingNexusHarkonnenBetrayal);
|
||||
for(const p of game.players) game=applyAction(game,p.id,{type:'setAutopilot',difficulty:'Easy'});
|
||||
assert.equal(JSON.stringify(game.pendingNexusHarkonnenBetrayal),source);
|
||||
game=applyAction(game,f.holder,{type:'nexusHarkonnenBetrayalUse',event:f.event});
|
||||
assert.equal(game.nexusHarkonnenBetrayalHistory!.length,1);
|
||||
assert.equal(game.nexusCards!.cards!.hands[other],'richese');
|
||||
});
|
||||
void test('Arbitrary credentials, payloads, stale event and committed native/source/stock corruption fail without cost or cleanup',()=>{
|
||||
const f=createHarkonnenNexusBetrayalFixture({remote:true}),action={type:'nexusHarkonnenBetrayalUse',event:f.event};
|
||||
reject(f.game,'absent',action);reject(f.game,f.provider,action);
|
||||
reject(f.game,f.holder,{...action,event:`${f.event}-old`});
|
||||
reject(f.game,f.holder,{...action,card:f.identity});
|
||||
const corruptions:Array<(g:Game)=>void>=[
|
||||
g=>{g.pendingNexusHarkonnenBetrayal!.source.declaration.identity='absent';},
|
||||
g=>{g.battle!.plans[f.target].leader=g.players.find(p=>p.id===f.target)!.leaders.find(l=>l.id!==f.identity)!.id;},
|
||||
g=>{g.battle!.nexusHarkonnenAllowance!.passed=[];},
|
||||
g=>{g.players.find(p=>p.id===f.provider)!.ally=null;},
|
||||
g=>{g.traitorReserve!.push(f.identity);},
|
||||
g=>{g.deck.push(g.deck[0]);},
|
||||
g=>{g.players.find(p=>p.id===f.provider)!.traitors=g.players.find(p=>p.id===f.provider)!.traitors.filter(c=>c!==f.identity);},
|
||||
];
|
||||
for(const mutate of corruptions) {const game=structuredClone(f.game);mutate(game);reject(game,f.holder,action);}
|
||||
});
|
||||
void test('Original printed Karama remote counter preempts Nexus without either Nexus or traitor cost',()=>{
|
||||
const f=createHarkonnenNexusBetrayalFixture({remote:true}),counter=f.beforeNativeCounter.players.find(p=>p.id!==f.provider&&p.hand.some(c=>c.effect==='karama'))!;
|
||||
assert.ok(counter);const card=counter.hand.find(c=>c.effect==='karama')!;
|
||||
let game=applyAction(f.beforeNativeCounter,counter.id,{type:'card',mode:'cancel',card:card.id});
|
||||
for(let i=0;game.response&&i<100;i++) {const seat=game.players.find(p=>!game.response!.passed.includes(p.id))!;game=applyAction(game,seat.id,{type:'passResponse'});}
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal,null);assert.equal(game.nexusHarkonnenBetrayalHistory!.length,0);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder],'harkonnen');
|
||||
assert.ok(game.players.find(p=>p.id===f.provider)!.traitors.includes(f.identity));
|
||||
assert.ok(game.discard.some(c=>c.id===card.id));
|
||||
assert.equal(game.battle!.traitorCalls[f.provider],false);stock(game);
|
||||
});
|
||||
void test('Actual Advanced BG Worthless conversion resolves before any Nexus declaration',()=>{
|
||||
const initial=createGame('HARKBG',newPlayer('bg','BG','beneGesserit'),true);
|
||||
joinGame(initial,newPlayer('h','Harkonnen','harkonnen'));joinGame(initial,newPlayer('a','Atreides','atreides'));joinGame(initial,newPlayer('g','Guild','guild'));
|
||||
const f=createHarkonnenNexusBetrayalFixture({initial,remote:true,advanced:true});
|
||||
const bg=f.beforeNativeCounter.players.find(p=>p.faction==='beneGesserit')!,card=bg.hand.find(c=>c.kind==='worthless')!;
|
||||
assert.ok(card);
|
||||
let game=applyAction(f.beforeNativeCounter,bg.id,{type:'card',mode:'cancel',card:card.id});
|
||||
for(let i=0;game.response&&i<100;i++) {const seat=game.players.find(p=>!game.response!.passed.includes(p.id))!;game=applyAction(game,seat.id,{type:'passResponse'});}
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal,null);assert.equal(game.nexusHarkonnenBetrayalHistory!.length,0);
|
||||
assert.equal(game.battle!.traitorCalls[f.provider],false);assert.ok(game.discard.some(c=>c.id===card.id));
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder],'harkonnen');stock(game);
|
||||
});
|
||||
void test('A real retirement shuffle may return the same physical identity as the private native draw, never an earlier read',()=>{
|
||||
const f=createHarkonnenNexusBetrayalFixture({remote:true}),original=Object.getOwnPropertyDescriptor(crypto,'getRandomValues');
|
||||
let first=true,spent:Game;
|
||||
crypto.getRandomValues=<T extends ArrayBufferView | null>(array:T):T=>{
|
||||
assert.ok(array);
|
||||
const values=new Uint32Array(array.buffer,array.byteOffset,array.byteLength/4);
|
||||
for(let i=0;i<values.length;i++) {values[i]=first?0:0xffffffff;first=false;}
|
||||
return array;
|
||||
};
|
||||
try {spent=use(f);} finally {
|
||||
if(original) Object.defineProperty(crypto,'getRandomValues',original);
|
||||
else Reflect.deleteProperty(crypto,'getRandomValues');
|
||||
}
|
||||
assert.equal(spent.traitorReserve![0],f.identity);
|
||||
const before=JSON.stringify(spent);
|
||||
for(const p of spent.players) viewGame(spent,p.id);
|
||||
assert.equal(JSON.stringify(spent),before);
|
||||
assert.equal(spent.players.find(p=>p.id===f.provider)!.traitors.length,3);
|
||||
const mentat=advanceHarkonnenBetrayalToMentat(spent);
|
||||
assert.equal(mentat.nexusHarkonnenReplacementHistory![0].drawn,f.identity);
|
||||
assert.equal(mentat.players.find(p=>p.id===f.provider)!.traitors.filter(card=>card===f.identity).length,1);
|
||||
assert.equal(mentat.players.find(p=>p.id===f.provider)!.revealedTraitors!.includes(f.identity),false);
|
||||
const own=viewGame(mentat,f.provider),rival=viewGame(mentat,f.holder);
|
||||
assert.ok(own.players.find(p=>p.id===f.provider)!.traitors!.includes(f.identity));
|
||||
assert.equal(rival.players.find(p=>p.id===f.provider)!.traitors,undefined);
|
||||
assert.equal('pendingNexusHarkonnenReplacement' in rival,false);
|
||||
assert.equal('nexusHarkonnenReplacementHistory' in rival,false);
|
||||
stock(mentat);
|
||||
});
|
||||
void test('Fresh admission rejects replay, modules and ordinary-game retrofit while preserving lobby immutably',()=>{
|
||||
let lobby=createGame('HARKBOUND',newPlayer('h','H','harkonnen'),false);joinGame(lobby,newPlayer('g','G','guild'));
|
||||
for(const p of lobby.players) lobby=applyAction(lobby,p.id,{type:'ready'});
|
||||
lobby.nexusCards={cards:null,phase:null};
|
||||
const before=JSON.stringify(lobby),started=initializeHarkonnenNexusBetrayalGameForAudit(lobby);
|
||||
assert.equal(JSON.stringify(lobby),before);assert.equal(started.nexusHarkonnenBetrayalPreview,true);
|
||||
assert.throws(()=>initializeHarkonnenNexusBetrayalGameForAudit(started));
|
||||
const modular=structuredClone(lobby);modular.expansions=['ix'];assert.throws(()=>initializeHarkonnenNexusBetrayalGameForAudit(modular));
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck, leaders, treacheryDeck } from '../game/cards';
|
||||
import { NEXUS_FACTIONS, type NexusState } from '../game/nexus-cards';
|
||||
import { createTraitorDeclaration } from '../game/traitor-declarations';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import {
|
||||
HarkonnenBetrayalError, closeHarkonnenBetrayalSource, createHarkonnenBetrayalReplacement,
|
||||
createHarkonnenBetrayalSource, drawHarkonnenBetrayalReplacement,
|
||||
harkonnenBetrayalEligible, harkonnenBetrayalResponders, initialHarkonnenBetrayalCursor,
|
||||
validateHarkonnenBetrayalFrame, validateHarkonnenBetrayalHistory,
|
||||
validateHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements,
|
||||
validateHarkonnenBetrayalSource,
|
||||
type HarkonnenBetrayalAuthority, type HarkonnenBetrayalContext,
|
||||
type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement,
|
||||
} from '../game/nexus-harkonnen-betrayal';
|
||||
|
||||
const parent = 'independently-produced-native-battle-parent';
|
||||
function context(remote = false, face: 'harkonnen' | 'richese' = 'harkonnen'): HarkonnenBetrayalContext {
|
||||
const players: HarkonnenBetrayalContext['players'] = [
|
||||
{ id: 'h', faction: 'harkonnen', ally: remote ? 'f' : null },
|
||||
{ id: 'a', faction: 'atreides', ally: null },
|
||||
{ id: 'e', faction: 'emperor', ally: null },
|
||||
{ id: 'f', faction: 'fremen', ally: remote ? 'h' : null },
|
||||
];
|
||||
const universe = traitorDeck(players.map(player => ({ leaders: leaders(player.faction) })));
|
||||
const custody = [
|
||||
{ id: 'h', faction: 'harkonnen' as const, traitors: ['emperor-0', 'emperor-1', 'fremen-0', 'atreides-0'] },
|
||||
{ id: 'a', faction: 'atreides' as const, traitors: ['harkonnen-0'] },
|
||||
{ id: 'e', faction: 'emperor' as const, traitors: ['harkonnen-1'] },
|
||||
{ id: 'f', faction: 'fremen' as const, traitors: ['harkonnen-2'] },
|
||||
];
|
||||
const held = custody.flatMap(player => player.traitors);
|
||||
const otherFace = face === 'harkonnen' ? 'richese' : 'guild';
|
||||
return {
|
||||
status: 'playing', phase: 6, turn: 2, advanced: false, sequence: 0, players,
|
||||
cards: { version: 1, deck: NEXUS_FACTIONS.filter(card => card !== face && card !== otherFace),
|
||||
discard: [], hands: { h: null, a: face, e: otherFace, f: null } },
|
||||
physicalCards: baseDeck(), universe,
|
||||
traitors: { reserve: universe.filter(identity => !held.includes(identity)), players: custody },
|
||||
battle: { event: 'native-battle-0', attacker: remote ? 'f' : 'h', defender: 'e', heroLeaderIds: [],
|
||||
plans: { [remote ? 'f' : 'h']: { leader: remote ? 'fremen-1' : 'harkonnen-3' },
|
||||
e: { leader: 'emperor-0' } } },
|
||||
};
|
||||
}
|
||||
function authority(ctx: HarkonnenBetrayalContext): HarkonnenBetrayalAuthority {
|
||||
const remote = ctx.battle.attacker !== 'h';
|
||||
const declaration = createTraitorDeclaration({ ...ctx.battle,
|
||||
players: ctx.players.map(player => ({ ...player,
|
||||
traitors: ctx.traitors.players.find(seat => seat.id === player.id)!.traitors })),
|
||||
}, 'h');
|
||||
return { provider: 'h', declaration, nativeWindow: remote ? 'harkonnenTraitor' : 'direct',
|
||||
nativeContext: `native-allowed:${ctx.battle.event}`,
|
||||
nativeRequired: remote ? ['a', 'e'] : [], nativePassed: remote ? ['e', 'a'] : [] };
|
||||
}
|
||||
function closed(ctx = context(), outcome: 'pass' | 'use' = 'use') {
|
||||
const native = authority(ctx);
|
||||
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
||||
return closeHarkonnenBetrayalSource(source, outcome, outcome === 'use' ? 'a' : null,
|
||||
initialHarkonnenBetrayalCursor());
|
||||
}
|
||||
function later(ctx: HarkonnenBetrayalContext): HarkonnenBetrayalContext {
|
||||
const next = structuredClone(ctx);
|
||||
next.sequence++;
|
||||
next.battle.event = `native-battle-${next.sequence}`;
|
||||
next.battle.plans.e.leader = 'emperor-1';
|
||||
const hand = next.traitors.players.find(player => player.id === 'h')!.traitors;
|
||||
hand.splice(hand.indexOf('emperor-0'), 1);
|
||||
next.traitors.reserve.unshift('emperor-0');
|
||||
next.cards.hands.a = null;
|
||||
next.cards.discard.push('harkonnen');
|
||||
return next;
|
||||
}
|
||||
function freeze(value: unknown): void {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return;
|
||||
for (const child of Object.values(value)) freeze(child);
|
||||
Object.freeze(value);
|
||||
}
|
||||
|
||||
for (const remote of [false, true]) {
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${remote ? 'allied remote' : 'personal'} call binds its canonical declaration and original allowed native source`, () => {
|
||||
const ctx = { ...context(remote), advanced }, native = authority(ctx);
|
||||
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
||||
assert.equal(source.declaration.beneficiary, remote ? 'f' : 'h');
|
||||
assert.equal(source.declaration.target, 'e');
|
||||
assert.equal(source.declaration.identity, 'emperor-0');
|
||||
validateHarkonnenBetrayalFrame(ctx, source, native, parent, ['a', 'e'], ['e']);
|
||||
const changed: HarkonnenBetrayalAuthority[] = [
|
||||
{ ...native, provider: 'a' }, { ...native, nativeContext: 'different-native-attempt' },
|
||||
{ ...native, nativeWindow: remote ? 'direct' : 'harkonnenTraitor' },
|
||||
{ ...native, declaration: { ...native.declaration, beneficiary: 'e' } },
|
||||
{ ...native, declaration: { ...native.declaration, target: 'a' } },
|
||||
{ ...native, declaration: { ...native.declaration, leader: 'emperor-1' } },
|
||||
{ ...native, declaration: { ...native.declaration, identity: 'emperor-1' } },
|
||||
{ ...native, declaration: { ...native.declaration, voter: 'a' } },
|
||||
{ ...native, declaration: { ...native.declaration, event: 'another-battle' } },
|
||||
];
|
||||
for (const invalid of changed) {
|
||||
assert.throws(() => validateHarkonnenBetrayalSource(ctx, source, invalid, parent), HarkonnenBetrayalError);
|
||||
}
|
||||
for (const invalid of [
|
||||
{ ...ctx, status: 'setup' }, { ...ctx, phase: 7 }, { ...ctx, turn: 3 },
|
||||
{ ...ctx, sequence: 1 }, { ...ctx, advanced: !advanced },
|
||||
]) {
|
||||
assert.throws(() => validateHarkonnenBetrayalSource(invalid, source, native, parent), HarkonnenBetrayalError);
|
||||
}
|
||||
assert.throws(() => validateHarkonnenBetrayalSource(ctx, source, native, 'other-parent'), HarkonnenBetrayalError);
|
||||
const kh = structuredClone(ctx);
|
||||
kh.battle.plans.e.kwisatz = true;
|
||||
assert.throws(() => createHarkonnenBetrayalSource(kh, native, parent), HarkonnenBetrayalError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('remote calls require all original native passes and correct live alliance roles', () => {
|
||||
const ctx = context(true), native = authority(ctx);
|
||||
for (const patch of [
|
||||
{ nativePassed: ['e'] }, { nativePassed: ['e', 'e', 'a'] },
|
||||
{ nativeRequired: ['a', 'e', 'absent'], nativePassed: ['e', 'a', 'absent'] },
|
||||
{ nativePassed: ['e', 'a', 'absent'] }, { nativeContext: '' },
|
||||
]) {
|
||||
assert.throws(() => createHarkonnenBetrayalSource(ctx, { ...native, ...patch }, parent), HarkonnenBetrayalError);
|
||||
}
|
||||
const unallied = structuredClone(ctx);
|
||||
unallied.players = unallied.players.map(player => ({ ...player, ally: null }));
|
||||
assert.throws(() => createHarkonnenBetrayalSource(unallied, native, parent), HarkonnenBetrayalError);
|
||||
const personal = context(), direct = authority(personal);
|
||||
assert.throws(() => createHarkonnenBetrayalSource(personal,
|
||||
{ ...direct, nativeRequired: ['a'], nativePassed: ['a'] }, parent), HarkonnenBetrayalError);
|
||||
});
|
||||
|
||||
void test('the matched opposing leader can be a captured classic leader, not only its faction original discs', () => {
|
||||
const ctx = context();
|
||||
ctx.battle.plans.e.leader = 'fremen-0';
|
||||
const native = authority(ctx);
|
||||
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
||||
const result = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
|
||||
assert.equal(source.declaration.target, 'e');
|
||||
assert.equal(source.declaration.identity, 'fremen-0');
|
||||
assert.equal(createHarkonnenBetrayalReplacement(result.receipt).identity, 'fremen-0');
|
||||
validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
|
||||
});
|
||||
|
||||
void test('a personal native Harkonnen defender declares the actual attacking leader', () => {
|
||||
const ctx = context();
|
||||
ctx.battle.attacker = 'e';
|
||||
ctx.battle.defender = 'h';
|
||||
const native = authority(ctx);
|
||||
native.nativeWindow = 'direct';
|
||||
native.nativeRequired = [];
|
||||
native.nativePassed = [];
|
||||
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
||||
assert.equal(source.declaration.beneficiary, 'h');
|
||||
assert.equal(source.declaration.target, 'e');
|
||||
const result = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
|
||||
validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
|
||||
});
|
||||
|
||||
void test('public acknowledgment membership is face neutral while only the private singleton owner may Use', () => {
|
||||
const real = context(), wrong = context(false, 'richese');
|
||||
const realSource = createHarkonnenBetrayalSource(real, authority(real), parent);
|
||||
const wrongSource = createHarkonnenBetrayalSource(wrong, authority(wrong), parent);
|
||||
assert.deepEqual(harkonnenBetrayalResponders(real.cards, real.players), ['a', 'e']);
|
||||
assert.deepEqual(harkonnenBetrayalResponders(wrong.cards, wrong.players), ['a', 'e']);
|
||||
assert.deepEqual(realSource.required, wrongSource.required);
|
||||
assert.equal(realSource.event, wrongSource.event);
|
||||
assert.equal(harkonnenBetrayalEligible(real.cards, real.players, 'a'), true);
|
||||
assert.equal(harkonnenBetrayalEligible(wrong.cards, wrong.players, 'a'), false);
|
||||
for (const holder of ['h', 'e', 'f', 'absent', null]) {
|
||||
assert.throws(() => closeHarkonnenBetrayalSource(realSource, 'use', holder,
|
||||
initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
|
||||
}
|
||||
assert.throws(() => closeHarkonnenBetrayalSource(wrongSource, 'use', 'a',
|
||||
initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
|
||||
const pass = closeHarkonnenBetrayalSource(wrongSource, 'pass', null, initialHarkonnenBetrayalCursor());
|
||||
validateHarkonnenBetrayalHistory([pass.receipt], pass.cursor, wrong.universe);
|
||||
const allied = context();
|
||||
allied.players = allied.players.map(player => ({ ...player,
|
||||
ally: player.id === 'a' ? 'f' : player.id === 'f' ? 'a' : null }));
|
||||
assert.deepEqual(harkonnenBetrayalResponders(allied.cards, allied.players), ['e']);
|
||||
assert.equal(harkonnenBetrayalEligible(allied.cards, allied.players, 'a'), false);
|
||||
const nativeHeld = context();
|
||||
nativeHeld.cards.hands.a = null;
|
||||
nativeHeld.cards.hands.h = 'harkonnen';
|
||||
assert.equal(harkonnenBetrayalEligible(nativeHeld.cards, nativeHeld.players, 'h'), false);
|
||||
assert.deepEqual(harkonnenBetrayalResponders(nativeHeld.cards, nativeHeld.players), ['e']);
|
||||
});
|
||||
|
||||
void test('frame rejects missing, reordered, duplicated or foreign responders and passes', () => {
|
||||
const ctx = context(), native = authority(ctx);
|
||||
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
||||
for (const [required, passed] of [
|
||||
[['a'], []], [['e', 'a'], []], [['a', 'e', 'h'], []], [['a', 'e', 'e'], []],
|
||||
[['a', 'e'], ['a', 'a']], [['a', 'e'], ['h']], [['a', 'e'], ['absent']],
|
||||
]) {
|
||||
assert.throws(() => validateHarkonnenBetrayalFrame(ctx, source, native, parent, required, passed), HarkonnenBetrayalError);
|
||||
}
|
||||
const changed = structuredClone(ctx);
|
||||
changed.cards.hands.e = null;
|
||||
changed.cards.deck.push('richese');
|
||||
assert.throws(() => validateHarkonnenBetrayalFrame(changed, source, native, parent, ['a', 'e'], []), HarkonnenBetrayalError);
|
||||
});
|
||||
|
||||
void test('canonical stock rejects fabricated, duplicate, missing or foreign physical cards and universes', () => {
|
||||
const ctx = context(), native = authority(ctx);
|
||||
for (const cards of [baseDeck().slice(1), [...baseDeck(), baseDeck()[0]], treacheryDeck(['ix']),
|
||||
baseDeck().map((card, index) => index === 0 ? baseDeck()[1] : card)]) {
|
||||
assert.throws(() => createHarkonnenBetrayalSource({ ...ctx, physicalCards: cards }, native, parent));
|
||||
}
|
||||
for (const patch of [{ name: 'Forged' }, { kind: 'special' }, { effect: 'fake' },
|
||||
{ id: 'fake' }, { extra: 'private-copy' }]) {
|
||||
assert.throws(() => createHarkonnenBetrayalSource({ ...ctx,
|
||||
physicalCards: [Object.assign({}, ctx.physicalCards[0], patch), ...ctx.physicalCards.slice(1)] }, native, parent));
|
||||
}
|
||||
for (const universe of [ctx.universe.slice(1), [...ctx.universe, ctx.universe[0]],
|
||||
ctx.universe.map((identity, index) => index === 0 ? 'cheap-hero-traitor' : identity)]) {
|
||||
assert.throws(() => createHarkonnenBetrayalSource({ ...ctx, universe }, native, parent));
|
||||
}
|
||||
const fabricated = structuredClone(ctx);
|
||||
fabricated.universe = fabricated.universe.map(identity => identity === 'emperor-0' ? 'forged-leader' : identity);
|
||||
fabricated.traitors.players[0].traitors[0] = 'forged-leader';
|
||||
assert.throws(() => createHarkonnenBetrayalSource(fabricated, native, parent));
|
||||
for (const variant of ['missing', 'duplicate', 'foreign', 'wrongRoster'] as const) {
|
||||
const changed = structuredClone(ctx);
|
||||
if (variant === 'missing') changed.traitors.reserve.pop();
|
||||
if (variant === 'duplicate') changed.traitors.reserve.push('emperor-0');
|
||||
if (variant === 'foreign') changed.traitors.reserve[0] = 'foreign-identity';
|
||||
if (variant === 'wrongRoster') changed.traitors.players[0].id = 'absent';
|
||||
assert.throws(() => createHarkonnenBetrayalSource(changed, native, parent));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Nexus census cannot hide missing, duplicate or foreign custody behind public parity', () => {
|
||||
const variants: NexusState[] = [];
|
||||
const duplicate = context().cards; duplicate.deck.push('harkonnen'); variants.push(duplicate);
|
||||
const missing = context().cards; missing.hands.a = null; variants.push(missing);
|
||||
const extra = context().cards; extra.hands.foreign = null; variants.push(extra);
|
||||
const unknown = context().cards; Object.assign(unknown.deck, { 0: 'unprinted-face' }); variants.push(unknown);
|
||||
for (const cards of variants) {
|
||||
assert.throws(() => harkonnenBetrayalResponders(cards, context().players));
|
||||
assert.throws(() => harkonnenBetrayalEligible(cards, context().players, 'a'));
|
||||
}
|
||||
const excluded = context();
|
||||
excluded.players = excluded.players.map(player => player.id === 'f' ? { ...player, faction: 'ixians' } : player);
|
||||
assert.throws(() => createHarkonnenBetrayalSource(excluded, authority(context()), parent));
|
||||
});
|
||||
|
||||
void test('pending source freezes current physical custody and native identity before any cost', () => {
|
||||
const ctx = context(), native = authority(ctx);
|
||||
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
||||
const transferred = structuredClone(ctx);
|
||||
transferred.traitors.players[0].traitors.shift();
|
||||
transferred.traitors.players[1].traitors.push('emperor-0');
|
||||
assert.throws(() => validateHarkonnenBetrayalSource(transferred, source, native, parent), HarkonnenBetrayalError);
|
||||
const reordered = structuredClone(ctx);
|
||||
reordered.traitors.reserve.reverse();
|
||||
assert.throws(() => validateHarkonnenBetrayalSource(reordered, source, native, parent), HarkonnenBetrayalError);
|
||||
const undeclared = structuredClone(ctx);
|
||||
undeclared.traitors.players[0].traitors.shift();
|
||||
undeclared.traitors.reserve.push('emperor-0');
|
||||
assert.throws(() => createHarkonnenBetrayalSource(undeclared, native, parent), HarkonnenBetrayalError);
|
||||
for (const patch of [
|
||||
{ signature: 'forged' }, { event: 'expired' }, { identity: 'forged' },
|
||||
{ provider: 'a' }, { required: ['a'] }, { eligible: 'e' }, { inventory: 'forged' },
|
||||
{ parent: 'forged' }, { extra: 'client-selector' },
|
||||
]) {
|
||||
const corrupt = Object.assign(structuredClone(source), patch);
|
||||
assert.throws(() => validateHarkonnenBetrayalSource(ctx, corrupt, native, parent), HarkonnenBetrayalError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('quotes and closure preserve frozen inputs and produce independent saved values', () => {
|
||||
const ctx = context(true), native = authority(ctx), snapshot = structuredClone({ ctx, native });
|
||||
freeze(ctx); freeze(native);
|
||||
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
||||
freeze(source);
|
||||
const cursor = initialHarkonnenBetrayalCursor(); freeze(cursor);
|
||||
const result = closeHarkonnenBetrayalSource(source, 'use', 'a', cursor);
|
||||
validateHarkonnenBetrayalFrame(ctx, source, native, parent, source.required, ['e']);
|
||||
validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
|
||||
assert.deepEqual({ ctx, native }, snapshot);
|
||||
assert.deepEqual(cursor, initialHarkonnenBetrayalCursor());
|
||||
assert.notEqual(result.receipt.source, source);
|
||||
assert.notEqual(result.receipt.source.declaration, source.declaration);
|
||||
assert.notEqual(result.receipt.source.battle, source.battle);
|
||||
assert.notEqual(result.receipt.source.required, source.required);
|
||||
});
|
||||
|
||||
void test('closed cursor is independent, exactly once, and rejects forged or missing history', () => {
|
||||
const ctx = context(), native = authority(ctx), source = createHarkonnenBetrayalSource(ctx, native, parent);
|
||||
const use = closed(ctx), pass = closed(ctx, 'pass');
|
||||
assert.throws(() => closeHarkonnenBetrayalSource(source, 'use', 'a', use.cursor), HarkonnenBetrayalError);
|
||||
assert.throws(() => closeHarkonnenBetrayalSource(source, 'pass', 'a', initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
|
||||
const next = later(ctx);
|
||||
const nextSource = createHarkonnenBetrayalSource(next, authority(next), 'next-native-parent');
|
||||
for (const cursor of [
|
||||
{ sequence: 0, signature: 'forged-genesis' },
|
||||
{ sequence: 1, signature: 'forged-head' },
|
||||
{ sequence: 1, signature: JSON.stringify(['harkonnenBetrayalHead', use.receipt.signature, 'use', 'a']) },
|
||||
{ sequence: 2, signature: use.cursor.signature },
|
||||
]) {
|
||||
assert.throws(() => closeHarkonnenBetrayalSource(nextSource, 'pass', null, cursor), HarkonnenBetrayalError);
|
||||
}
|
||||
for (const history of [[], [use.receipt, use.receipt], [pass.receipt]]) {
|
||||
assert.throws(() => validateHarkonnenBetrayalHistory(history, use.cursor, ctx.universe), HarkonnenBetrayalError);
|
||||
}
|
||||
for (const patch of [{ previous: 'forged' }, { signature: 'forged' }, { outcome: 'pass' }, { holder: 'e' }]) {
|
||||
assert.throws(() => validateHarkonnenBetrayalHistory([Object.assign(structuredClone(use.receipt), patch)], use.cursor), HarkonnenBetrayalError);
|
||||
}
|
||||
assert.throws(() => validateHarkonnenBetrayalHistory([use.receipt], { ...use.cursor, signature: 'forged' }), HarkonnenBetrayalError);
|
||||
assert.throws(() => validateHarkonnenBetrayalHistory([use.receipt], use.cursor, ctx.universe.slice(1)), HarkonnenBetrayalError);
|
||||
});
|
||||
|
||||
void test('historical Use and its pending replacement survive return/recycling and later all-pass native attempts', () => {
|
||||
const ctx = context(), first = closed(ctx);
|
||||
const replacement = createHarkonnenBetrayalReplacement(first.receipt);
|
||||
const next = later(ctx), native = authority(next);
|
||||
const source = createHarkonnenBetrayalSource(next, native, 'independent-later-native-parent');
|
||||
const pass = closeHarkonnenBetrayalSource(source, 'pass', null, first.cursor);
|
||||
const history = [first.receipt, pass.receipt];
|
||||
validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
|
||||
validateHarkonnenBetrayalReplacement(replacement, history, pass.cursor, next.universe);
|
||||
validateHarkonnenBetrayalReplacements(history, pass.cursor, [replacement], next.universe);
|
||||
assert.equal(replacement.event, first.receipt.source.event);
|
||||
assert.notEqual(replacement.event, pass.receipt.source.event);
|
||||
// A real shuffle can put the exact returned identity on top again.
|
||||
const drawn = drawHarkonnenBetrayalReplacement(replacement, history, pass.cursor,
|
||||
next.traitors.reserve[0], next.universe);
|
||||
assert.equal(drawn.drawn, replacement.identity);
|
||||
next.traitors.players[0].traitors.push(next.traitors.reserve.shift()!);
|
||||
next.cards.discard = [];
|
||||
next.cards.hands.f = 'harkonnen';
|
||||
validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
|
||||
validateHarkonnenBetrayalReplacements(history, pass.cursor, [drawn], next.universe);
|
||||
assert.equal(drawn.status, 'drawn');
|
||||
assert.throws(() => drawHarkonnenBetrayalReplacement(drawn, history, pass.cursor, 'emperor-0'), HarkonnenBetrayalError);
|
||||
assert.equal(replacement.status, 'due');
|
||||
assert.equal(replacement.drawn, null);
|
||||
});
|
||||
|
||||
void test('each successful Use has exactly one due or actual completed canonical private draw', () => {
|
||||
const ctx = context(), use = closed(ctx), pass = closed(ctx, 'pass');
|
||||
const due = createHarkonnenBetrayalReplacement(use.receipt), history = [use.receipt];
|
||||
assert.throws(() => createHarkonnenBetrayalReplacement(pass.receipt), HarkonnenBetrayalError);
|
||||
assert.throws(() => validateHarkonnenBetrayalReplacements(history, use.cursor, []), HarkonnenBetrayalError);
|
||||
assert.throws(() => validateHarkonnenBetrayalReplacements(history, use.cursor, [due, due]), HarkonnenBetrayalError);
|
||||
assert.throws(() => validateHarkonnenBetrayalReplacements([pass.receipt], pass.cursor, [due]), HarkonnenBetrayalError);
|
||||
for (const patch of [
|
||||
{ provider: 'a' }, { turn: 3 }, { event: 'another-use' }, { identity: 'emperor-1' },
|
||||
{ sequence: 1 }, { receipt: pass.cursor.signature }, { signature: 'forged' },
|
||||
{ status: 'drawn', drawn: null }, { status: 'due', drawn: 'emperor-0' },
|
||||
{ status: 'drawn', drawn: 'forged-identity' }, { extra: 'selector' },
|
||||
]) {
|
||||
const corrupt = Object.assign(structuredClone(due), patch);
|
||||
assert.throws(() => validateHarkonnenBetrayalReplacement(corrupt, history, use.cursor), HarkonnenBetrayalError);
|
||||
}
|
||||
assert.throws(() => drawHarkonnenBetrayalReplacement(due, history, use.cursor, 'forged-identity'), HarkonnenBetrayalError);
|
||||
freeze(due);
|
||||
const drawn = drawHarkonnenBetrayalReplacement(due, history, use.cursor, 'atreides-1');
|
||||
validateHarkonnenBetrayalReplacements(history, use.cursor, [drawn]);
|
||||
assert.equal(drawn.drawn, 'atreides-1');
|
||||
});
|
||||
|
||||
void test('bounded prototype rejects simultaneous replacement obligations but retains multiple completed Uses', () => {
|
||||
const ctx = context(), first = closed(ctx), firstDue = createHarkonnenBetrayalReplacement(first.receipt);
|
||||
const next = later(ctx);
|
||||
next.turn = 3;
|
||||
next.cards.discard = [];
|
||||
next.cards.hands.a = 'harkonnen';
|
||||
const source = createHarkonnenBetrayalSource(next, authority(next), 'next-turn-native-parent');
|
||||
const second = closeHarkonnenBetrayalSource(source, 'use', 'a', first.cursor);
|
||||
const secondDue = createHarkonnenBetrayalReplacement(second.receipt), history = [first.receipt, second.receipt];
|
||||
assert.throws(() => validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDue, secondDue]), HarkonnenBetrayalError);
|
||||
const firstDrawn = drawHarkonnenBetrayalReplacement(firstDue, history, second.cursor, 'emperor-0');
|
||||
validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDrawn, secondDue]);
|
||||
const secondDrawn = drawHarkonnenBetrayalReplacement(secondDue, history, second.cursor, 'emperor-1');
|
||||
validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDrawn, secondDrawn]);
|
||||
assert.throws(() => validateHarkonnenBetrayalReplacements(history, second.cursor, [secondDrawn]), HarkonnenBetrayalError);
|
||||
});
|
||||
|
||||
void test('native attempt history serialization stays linear with nonrecursive current and previous heads', () => {
|
||||
const ctx = context(), history: HarkonnenBetrayalReceipt[] = [];
|
||||
let cursor = initialHarkonnenBetrayalCursor(), halfway = 0;
|
||||
const replacements: HarkonnenBetrayalReplacement[] = [];
|
||||
for (let sequence = 0; sequence < 80; sequence++) {
|
||||
ctx.sequence = sequence;
|
||||
ctx.battle.event = `native-battle-${sequence}`;
|
||||
const source = createHarkonnenBetrayalSource(ctx, authority(ctx), `original-native-parent-${sequence}`);
|
||||
const result = closeHarkonnenBetrayalSource(source, 'pass', null, cursor);
|
||||
history.push(result.receipt);
|
||||
cursor = result.cursor;
|
||||
if (sequence === 39) halfway = JSON.stringify(history).length;
|
||||
}
|
||||
validateHarkonnenBetrayalHistory(history, cursor, ctx.universe);
|
||||
validateHarkonnenBetrayalReplacements(history, cursor, replacements, ctx.universe);
|
||||
assert.ok(JSON.stringify(history).length < halfway * 2.1);
|
||||
assert.ok(cursor.signature.length < 400);
|
||||
});
|
||||
@@ -652,3 +652,156 @@ void test('Ixian Betrayal CLI preserves a private consistent exact-version backu
|
||||
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
|
||||
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
|
||||
});
|
||||
|
||||
function harkonnenLobby(advanced = true, seats = 3) {
|
||||
const game = createGame('PROTOTYP', newPlayer('h', 'Native Harkonnen', 'harkonnen'), advanced);
|
||||
const factions = ['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit'] as const;
|
||||
for (let index = 0; index < seats - 1; index++)
|
||||
joinGame(game, newPlayer(`p${index}`, `Original ${index}`, factions[index]));
|
||||
for (const player of game.players) player.ready = true;
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const seats of [2, 3, 4, 5, 6]) {
|
||||
void test(`Harkonnen Betrayal ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry preserves seats and leaves all Nexus physically undealt`, t => {
|
||||
const { db } = fixture(t);
|
||||
const lobby = harkonnenLobby(advanced, seats);
|
||||
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(lobby), lobby.code);
|
||||
const credentials = db.prepare('SELECT * FROM seats').all();
|
||||
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
|
||||
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'harkonnen-betrayal'));
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
||||
const result = startPrototypeRoom(db, lobby.code, 7, 'harkonnen-betrayal');
|
||||
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(lobby.code)!.state as string) as Game;
|
||||
assert.equal(result.version, 8);
|
||||
assert.equal(saved.version, 8);
|
||||
assert.equal(saved.status, 'setup');
|
||||
assert.equal(saved.nexusHarkonnenBetrayalPreview, true);
|
||||
assert.equal(saved.advanced, advanced);
|
||||
assert.equal(saved.host, lobby.host);
|
||||
assert.deepEqual(saved.expansions, []);
|
||||
assert.deepEqual(saved.playerPositions, lobby.playerPositions);
|
||||
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
|
||||
const stock = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand)];
|
||||
assert.equal(stock.length, 33);
|
||||
assert.equal(new Set(stock.map(card => card.id)).size, 33);
|
||||
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
|
||||
assert.deepEqual(saved.nexusCards!.cards!.discard, []);
|
||||
for (const player of saved.players) {
|
||||
assert.equal(saved.nexusCards!.cards!.hands[player.id], null);
|
||||
const view = viewGame(saved, player.id);
|
||||
assert.equal(view.nexusHarkonnenBetrayalReaction, null);
|
||||
assert.equal(view.nexusCards!.card, null);
|
||||
assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
|
||||
}
|
||||
const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'harkonnen-betrayal'));
|
||||
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'nexus'));
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('Harkonnen Betrayal rejects incompatible lobbies, overlays and ordinary public starts without changing durable records', t => {
|
||||
const { db } = fixture(t);
|
||||
const native = harkonnenLobby();
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
game => { game.players[0].ready = false; },
|
||||
game => { game.code = 'NOTMATCH'; },
|
||||
game => { game.players[0].faction = 'fremen'; },
|
||||
game => { game.players[1].faction = 'harkonnen'; },
|
||||
...(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const).map(faction =>
|
||||
(game: Game) => { game.players[1].faction = faction; }),
|
||||
...(['ix', 'choam', 'ecaz'] as const).map(expansion =>
|
||||
(game: Game) => { game.expansions = [expansion]; }),
|
||||
game => { game.discoveryEnabled = true; },
|
||||
game => { game.ecazTreachery = true; },
|
||||
game => { game.semutaPreview = true; },
|
||||
game => { game.nexusIxianReplacementPreview = true; },
|
||||
game => { game.nexusIxianBetrayalPreview = true; },
|
||||
game => { game.nexusHarkonnenBetrayalPreview = true; },
|
||||
game => { game.kullPreview = true; },
|
||||
game => { game.nexusKullPreview = true; },
|
||||
game => { game.guildBetrayalPreview = true; },
|
||||
game => { game.richeseBetrayalPreview = true; },
|
||||
game => { game.moritaniAssassinatePreview = true; },
|
||||
game => { game.leaderSkills = createLeaderSkills(() => 0.5); },
|
||||
game => { game.players.splice(1); },
|
||||
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
|
||||
];
|
||||
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
|
||||
const game = applyAction(native, native.host, { type, enabled: true });
|
||||
for (const player of game.players) player.ready = true;
|
||||
return game;
|
||||
});
|
||||
for (const game of [...changes.map(change => {
|
||||
const game = structuredClone(native);
|
||||
change(game);
|
||||
return game;
|
||||
}), ...modules]) {
|
||||
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
|
||||
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
const seats = db.prepare('SELECT * FROM seats').all();
|
||||
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'harkonnen-betrayal'));
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||
}
|
||||
const original = structuredClone(native);
|
||||
for (const action of [{ type: 'start', profile: 'harkonnen-betrayal' }, { type: 'initializeHarkonnenNexusBetrayalGameForAudit' }])
|
||||
assert.throws(() => applyAction(native, native.host, action));
|
||||
assert.deepEqual(native, original);
|
||||
});
|
||||
|
||||
void test('Harkonnen Betrayal CLI backs up every room and seat privately before the exact fresh-version start and cannot redeal or overwrite proof', t => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dune-harkonnen-betrayal-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const database = join(directory, 'rooms.sqlite');
|
||||
const db = new DatabaseSync(database);
|
||||
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
|
||||
const lobby = harkonnenLobby();
|
||||
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100);
|
||||
const other = harkonnenLobby(false, 2);
|
||||
other.code = 'KEEPGAME';
|
||||
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
|
||||
startPrototypeRoom(db, other.code, 9, 'harkonnen-betrayal');
|
||||
for (const player of lobby.players)
|
||||
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, player.id, 'isolated-test-' + player.id);
|
||||
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
const seats = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
|
||||
db.close();
|
||||
const output = join(directory, 'private-proof');
|
||||
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
|
||||
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
|
||||
'--profile', 'harkonnen-betrayal', '--db', database, '--room', lobby.code,
|
||||
'--version', String(version), '--out', out,
|
||||
], { encoding: 'utf8', timeout: 120000 });
|
||||
const result = invoke(7, output);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(statSync(output).mode & 0o777, 0o700);
|
||||
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
|
||||
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
|
||||
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
|
||||
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
||||
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
|
||||
backup.close();
|
||||
const current = new DatabaseSync(database, { readOnly: true });
|
||||
t.after(() => current.close());
|
||||
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
|
||||
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
|
||||
const row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
|
||||
const saved = JSON.parse(row.state as string) as Game;
|
||||
assert.equal(row.version, 8);
|
||||
assert.equal(saved.nexusHarkonnenBetrayalPreview, true);
|
||||
assert.equal(saved.status, 'setup');
|
||||
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
|
||||
assert.notEqual(invoke(8, output).status, 0);
|
||||
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
|
||||
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
|
||||
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
|
||||
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
|
||||
});
|
||||
Reference in new issue
Block a user