Integrate Ixian Nexus Betrayal at original native attempts

This commit is contained in:
Quintine committed 2026-10-01 21:58:07 +10:00
1 parent 49c99de3e2
commit cdfe0e5370
25 files changed
+2824 -63

No files matched your search

+92
View File
@@ -4,6 +4,9 @@ import { DatabaseSync } from 'node:sqlite';
import { adminStore, pauseAdminBatch } from './admin-access-fixture';
import { adminLogin, requireAdmin, adminLogoutAll, AdminError } from '../db/admin-access';
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';
const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
status, advanced, host: 'p1', turn: 1, phase: 0, expansions: [],
@@ -48,6 +51,95 @@ void test('admin directory allowlist reveals no private state, role power or sea
assert.deepEqual(decision.pending, { label: 'Special decision', owners: ['p1'] });
assert.equal(JSON.stringify(decision).includes('SECRET'), false);
});
const project = (value: unknown) => projectAdminRoom({ code: 'IXADMINA', version: 3, updated_at: 100, state: JSON.stringify(value) });
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 });
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: 'nexusIxianBetrayalPass', event: f.event });
const remaining = f.required.find(id => id !== f.holder)!;
const sole = project(partial);
assert.deepEqual(sole.pending.owners, [remaining]);
assert.notEqual(remaining, f.provider);
for (const player of partial.players) assert.equal(tableActionOwner(viewGame(partial, player.id)), remaining);
assert.equal(sole.pending.label, shared.pending.label, 'Passing does not reveal which private card can prevent the attempt.');
const privateChanges = {
...partial,
players: partial.players.map(player => ({ ...player, hand: ['SECRET_HAND'], spice: 'SECRET_PRICE' })),
auction: partial.auction && { ...partial.auction, price: 'SECRET_PRICE' },
pendingNexusIxianBetrayal: {
...partial.pendingNexusIxianBetrayal,
source: { eligible: 'SECRET_ELIGIBILITY', nativeContext: 'SECRET_NATIVE', parent: 'SECRET_PARENT', card: 'SECRET_SOURCE_CARD' },
},
};
assert.deepEqual(project(privateChanges), sole, 'Admin metadata depends on public membership, not private source validity or custody.');
assert.equal(JSON.stringify(sole).includes('SECRET'), false);
}
});
void test('an irrelevant held Nexus face has the same admin acknowledgement metadata as an eligible face', () => {
for (const kind of ['technology', 'bidding'] as const) {
const eligible = createIxianNexusBetrayalFixture({ kind, receiverCount: 2, face: 'ixians', secondFace: 'guild' });
const irrelevant = createIxianNexusBetrayalFixture({ kind, receiverCount: 2, face: 'richese', secondFace: 'guild' });
assert.equal(viewGame(eligible.game, eligible.holder).nexusIxianBetrayalReaction!.canUse, true);
assert.equal(viewGame(irrelevant.game, irrelevant.holder).nexusIxianBetrayalReaction!.canUse, false);
assert.deepEqual(project(eligible.game), project(irrelevant.game));
const eligiblePartial = applyAction(eligible.game, eligible.holder, { type: 'nexusIxianBetrayalPass', event: eligible.event });
const irrelevantPartial = applyAction(irrelevant.game, irrelevant.holder, { type: 'nexusIxianBetrayalPass', event: irrelevant.event });
assert.deepEqual(project(eligiblePartial), project(irrelevantPartial));
}
});
void test('Ixian admin acknowledgements preserve lifecycle and higher public pending priorities', () => {
const input = {
...state('Priority QA', 'playing'), phase: 3,
pendingNexusIxianBetrayal: { required: ['p1'], passed: [], source: 'SECRET_SOURCE' },
auction: { active: 'p1', price: 'SECRET_PRICE' },
};
const higherPriority = [
{ 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' } },
{ battle: { attacker: 'p1', defender: 'UNSEATED_SECRET' } },
{ nexus: true },
{ phase: 0, stormDialers: ['p1'], stormDials: {} },
];
for (const priority of higherPriority) {
const value = { ...input, ...priority };
const withGate = project(value);
const withoutGate = project({ ...value, pendingNexusIxianBetrayal: null });
assert.deepEqual(withGate, withoutGate);
assert.equal(JSON.stringify(withGate).includes('SECRET'), false);
}
});
void test('malformed or orphan Ixian membership cannot disclose an unseated owner or fall through to the bidder', () => {
const input = { ...state('Membership QA', 'playing'), phase: 3, auction: { active: 'p1' } };
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, pendingNexusIxianBetrayal: { ...membership, source: 'SECRET_SOURCE' } });
assert.deepEqual(output.pending.owners, []);
assert.equal(output.status, 'playing', 'Malformed optional membership does not hide otherwise readable public room metadata.');
assert.equal(JSON.stringify(output).includes('SECRET'), false);
}
const duplicate = project({ ...input, pendingNexusIxianBetrayal: { required: ['p1', 'p1', 'UNSEATED_SECRET'], passed: [] } });
assert.deepEqual(duplicate.pending.owners, ['p1']);
});
void test('admin directory filters, stable pages, literal wildcard search and nested malformed saves', async () => {
const f = await fixture();
try {
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, 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 { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-betrayal';
const scenarios = [
{ kind: 'bidding', advanced: false },
{ kind: 'bidding', advanced: true },
{ kind: 'technology', advanced: true },
] as const;
void test('all four profiles prevent both actual native advantages using only their own legal source projection', () => {
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
const fixture = createIxianNexusBetrayalFixture(scenario);
const own = viewGame(fixture.game, fixture.holder);
own.players.find(seat => seat.id === fixture.holder)!.bot = difficulty;
for (const rival of own.players.filter(seat => seat.id !== own.me)) {
delete rival.hand;
delete rival.spice;
}
const actions = botActions(own);
assert.deepEqual(actions, [{ type: 'nexusIxianBetrayalUse', event: fixture.event }]);
const result = applyAction(fixture.game, fixture.holder, actions[0]);
assert.equal(viewGame(result, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(result.nexusCards!.cards!.hands[fixture.holder], null);
assert.ok(result.nexusCards!.cards!.discard.includes('ixians'));
assert.deepEqual(result.players.map(seat => ({ id: seat.id, spice: seat.spice, hand: seat.hand })),
fixture.game.players.map(seat => ({ id: seat.id, spice: seat.spice, hand: seat.hand })));
if (scenario.kind === 'bidding') {
assert.equal(result.auction!.cards.length, fixture.nativeCount);
assert.equal(result.ixAuction, null);
} else {
assert.equal(result.pendingIxTechnology, null);
assert.equal(result.ixTechnologyTurn, result.turn);
assert.deepEqual(result.auction!.cards, fixture.game.auction!.cards);
}
}
});
void test('all profiles acknowledge irrelevant faces and every required seat exactly once without bidding fallthrough or deadlock', () => {
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
const fixture = createIxianNexusBetrayalFixture({ ...scenario, face: 'richese', secondFace: 'guild', receiverCount: 2 });
let game = structuredClone(fixture.game);
for (const seat of game.players) {
const own = viewGame(game, seat.id);
own.players.find(player => player.id === seat.id)!.bot = difficulty;
const reaction = own.nexusIxianBetrayalReaction;
if (!reaction) break;
const actions = botActions(own);
if (!reaction.canPass || reaction.hasPassed) {
assert.deepEqual(actions, []);
continue;
}
assert.deepEqual(actions, [{ type: 'nexusIxianBetrayalPass', event: fixture.event }]);
game = applyAction(game, seat.id, actions[0]);
const passed = viewGame(game, seat.id);
passed.players.find(player => player.id === seat.id)!.bot = difficulty;
if (passed.nexusIxianBetrayalReaction) assert.deepEqual(botActions(passed), []);
}
assert.equal(viewGame(game, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(game.nexusCards!.cards!.hands[fixture.holder], 'richese');
if (scenario.kind === 'bidding') {
assert.equal(game.decision?.kind, 'ixAuction');
assert.equal(game.ixAuction!.cards.length, fixture.nativeCount + 1);
} else {
assert.ok(fixture.selected);
assert.equal(game.auction!.cards[game.auction!.index].id, fixture.selected.id);
assert.equal(game.players.find(seat => seat.id === fixture.provider)!.hand.some(card => card.id === fixture.selected!.id), false);
assert.equal(game.pendingIxTechnology, null);
}
}
});
void test('seat-private ownership, expired events and room scheduling gates override held-card hints for every policy', () => {
for (const difficulty of DIFFICULTIES) {
const fixture = createIxianNexusBetrayalFixture();
const own: GameView = viewGame(fixture.game, fixture.holder);
own.players.find(seat => seat.id === own.me)!.bot = difficulty;
for (const reason of ['blocked', 'canUse'] as const) {
const blocked = structuredClone(own);
if (reason === 'blocked') blocked.nexusIxianBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
else blocked.nexusIxianBetrayalReaction!.canUse = false;
assert.deepEqual(botActions(blocked), [{ type: 'nexusIxianBetrayalPass', event: fixture.event }]);
}
for (const reason of ['canPass', 'hasPassed', 'event', 'paused', 'closed', 'automatic', 'finished'] as const) {
const blocked = structuredClone(own);
if (reason === 'canPass') blocked.nexusIxianBetrayalReaction!.canPass = false;
else if (reason === 'hasPassed') blocked.nexusIxianBetrayalReaction!.hasPassed = true;
else if (reason === 'event') blocked.nexusIxianBetrayalReaction!.event = '';
else if (reason === 'paused' || reason === 'closed') blocked.roomControl = { ...DEFAULT_ROOM_CONTROL, [reason]: true };
else if (reason === 'automatic') blocked.automaticContinuationPending = true;
else blocked.status = 'finished';
assert.deepEqual(botActions(blocked), []);
}
for (const seat of fixture.game.players.filter(seat => seat.id !== fixture.holder)) {
const rival = viewGame(fixture.game, seat.id);
rival.players.find(player => player.id === seat.id)!.bot = difficulty;
assert.deepEqual(botActions(rival), []);
}
}
});
void test('human seats wait for delegation; the real bot runner executes Use or every neutral Pass for all profiles', () => {
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
for (const face of ['ixians', 'richese'] as const) {
const fixture = createIxianNexusBetrayalFixture({ ...scenario, face, secondFace: 'guild', receiverCount: 2 });
const human = viewGame(fixture.game, fixture.holder);
const seat = human.players.find(player => player.id === human.me)!;
delete seat.bot;
delete seat.autopilot;
assert.deepEqual(botActions(human), []);
let delegated = fixture.game;
for (const id of fixture.required) delegated = applyAction(delegated, id, { type: 'setAutopilot', difficulty });
let result = delegated;
for (let step = 0; step < fixture.required.length && viewGame(result, fixture.holder).nexusIxianBetrayalReaction; step++) {
result = runBots(result, 1);
}
assert.equal(viewGame(result, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(result.nexusCards!.cards!.hands[fixture.holder], face === 'ixians' ? null : 'richese');
if (scenario.kind === 'bidding') {
assert.equal(face === 'ixians' ? result.auction!.cards.length : result.ixAuction!.cards.length,
fixture.nativeCount + (face === 'ixians' ? 0 : 1));
} else {
assert.equal(result.auction!.cards[result.auction!.index].id,
face === 'ixians' ? fixture.game.auction!.cards[fixture.game.auction!.index].id : fixture.selected!.id);
}
}
}
});
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import {applyAction,createGame,initializeIxianNexusBetrayalGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine';
import {botActions} from '../game/bots';
import type {Card} from '../game/cards';
export type IxianBetrayalFixtureOptions = {initial?:Game;kind?:'bidding'|'technology';advanced?:boolean;face?:'ixians'|'richese';secondFace?:'guild'|'ixians'|'richese';seatIds?:string[];receiverCount?:1|2};
export type IxianBetrayalFixture = {beforeNativeAttempt:Game;actor:string;declarationAction:Action;beforeNativeCounter:Game;game:Game;event:string;provider:string;holder:string;required:string[];kind:'bidding'|'technology';selected:Card|null;nativeCount:number};
export function settleIxianBetrayalNativeCounters(state:Game):Game {
let game=state;
for(let i=0;i<100;i++) {
if(game.pendingNexusIxianBetrayal || !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('The original native counters did not settle.');
}
function nativeStep(game:Game):{actor:string;action:Action} {
if(game.pendingNexusIxianBetrayal) {
const frame=game.pendingNexusIxianBetrayal;
return {actor:frame.required.find(id=>!frame.passed.includes(id))!,action:{type:'nexusIxianBetrayalPass',event:frame.source.event}};
}
if(game.nexusCards?.phase?.stage==='drawing') {
const phase=game.nexusCards.phase;
const 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.decision?.kind==='ixTechnology')
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.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);
const action=actions.find(a=>a.type==='ready')??actions[0];
if(action) return {actor:p.id,action};
}
throw new Error(`No native source action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
function step(game:Game):Game {const next=nativeStep(game);return applyAction(game,next.actor,next.action);}
/** Conserved original inventory, genuine setup, full first turn, settled nonholder
* alliance and actual Nexus draw. No phases, cards, forces or sources are injected. */
export function createIxianNexusBetrayalFixture(options:IxianBetrayalFixtureOptions={}):IxianBetrayalFixture {
const kind=options.kind??'bidding', face=options.face??'ixians';
let game:Game;
if(options.initial) {
game=structuredClone(options.initial);
assert.ok(game.status==='lobby'||(game.status==='setup'&&game.nexusIxianBetrayalPreview&&game.turn===1&&game.phase===0&&game.players.every(p=>p.hand.length===0)),'Only an admitted fresh undealt profile may 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('IXIANBETRAYAL',newPlayer(ids[0],'Atreides','atreides'),options.advanced??kind==='technology',['ix']);
joinGame(game,newPlayer(ids[1],'Ixians','ixians'));
joinGame(game,newPlayer(ids[2],'Guild','guild'));
if(ids.length>3) joinGame(game,newPlayer(ids[3],'Emperor','emperor'));
}
if(kind==='technology') assert.equal(game.advanced,true);
const provider=game.players.find(p=>p.faction==='ixians')!.id;
const holder=game.players.find(p=>p.faction==='atreides')?.id??game.players.find(p=>p.id!==provider)!.id;
const second=options.receiverCount===2?game.players.find(p=>p.id!==holder&&p.id!==provider)!.id:null;
const partners=game.players.filter(p=>p.id!==holder&&p.id!==second);
assert.ok(partners.length>=2,'Original Nexus draw needs two nonreceiver allies.');
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=initializeIxianNexusBetrayalGameForAudit(game);
}
// A native Ix setup chooses from conserved faces. Printed-counter profiles
// reserve a Karama in the shuffled non-Ix deal; Advanced BG-counter profiles
// give every non-Ix seat an original worthless card for its real conversion.
const karam=game.deck.filter(c=>c.effect==='karama');
assert.ok(karam.length>=2);
const other=game.deck.filter(c=>c.effect!=='karama');
if(game.advanced&&game.players.some(p=>p.faction==='beneGesserit')) {
const worthless=other.filter(c=>c.kind==='worthless');
const ixCard=karam[0];
assert.ok(worthless.length>=game.players.length-1);
const dealt=[ixCard,...worthless.slice(0,game.players.length-1)];
game.deck=[...dealt,...game.deck.filter(c=>!dealt.includes(c))];
} else {
game.deck=[karam[0],...other.slice(0,game.players.length-1),karam[1],...other.slice(game.players.length-1),...karam.slice(2)];
}
const nx=game.nexusCards!.cards!, faces:typeof nx.deck=[face,...(second?[options.secondFace??(face==='ixians'?'richese':'ixians')]:[])];
assert.equal(new Set(faces).size,faces.length,'Only distinct conserved singleton Nexus faces may be ordered.');
nx.deck=[...faces,...nx.deck.filter(c=>!faces.includes(c))];
for(let i=0;game.status==='setup'&&i<150;i++) {
if(game.decision?.kind==='ixSetup') {
const bgCounter=game.advanced&&game.players.some(p=>p.faction==='beneGesserit');
const card=game.ixSetupCards!.find(c=>bgCounter?c.effect==='karama':c.effect!=='karama')!;
game=applyAction(game,provider,{type:'decision',card:card.id});
} else game=step(game);
}
assert.equal(game.status,'playing');
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.phaseOpening&&!game.response&&!game.decision)&&i<1500;i++) game=step(game);
assert.equal(game.turn,2);
for(let i=0;game.phase!==1&&i<200;i++) game=step(game);
const worm=game.spiceDeck.findIndex(c=>'worm'in c&&!c.greatMaker&&!c.suppressed);
assert.ok(worm>=0);game.spiceDeck.splice(0,0,game.spiceDeck.splice(worm,1)[0]);
for(let i=0;!(game.nexus&&!game.spiceWindow&&!game.spiceResolution&&!game.phaseOpening&&!game.response&&!game.decision)&&i<200;i++) game=step(game);
assert.equal(game.nexus,true);
game=applyAction(game,partners[0].id,{type:'alliance',target:partners[1].id});
game=applyAction(game,partners[1].id,{type:'alliance',target:partners[0].id});
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!.phase!.stage,'complete');
assert.equal(game.nexusCards!.cards!.hands[holder],face);
let beforeNativeAttempt:Game|undefined, declarationAction:Action|undefined, actor:string|undefined;
for(let i=0;i<400;i++) {
if(kind==='technology'&&game.decision?.kind==='ixTechnology') {
beforeNativeAttempt=structuredClone(game);actor=provider;
declarationAction={type:'decision',card:game.players.find(p=>p.id===provider)!.hand[0].id};
game=applyAction(game,actor,declarationAction);break;
}
const next=nativeStep(game),before=structuredClone(game);
game=applyAction(game,next.actor,next.action);
if(kind==='bidding'&&game.response?.kind==='ixAuction') {beforeNativeAttempt=before;actor=next.actor;declarationAction=next.action;break;}
}
assert.ok(beforeNativeAttempt&&declarationAction&&actor);
assert.equal(game.response?.kind,kind==='bidding'?'ixAuction':'ixTechnology');
const beforeNativeCounter=structuredClone(game),nativeCount=game.ixAuction?.count??game.auction!.cards.length;
const selected=kind==='technology'?structuredClone(game.players.find(p=>p.id===provider)!.hand.find(c=>c.id===game.pendingIxTechnology!.card)!):null;
game=settleIxianBetrayalNativeCounters(game);
const frame=game.pendingNexusIxianBetrayal;assert.ok(frame);
return {beforeNativeAttempt,actor,declarationAction,beforeNativeCounter,game,event:frame.source.event,provider,holder,required:[...frame.required],kind,selected,nativeCount};
}
@@ -0,0 +1,182 @@
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 } from '../game/engine';
import type { Action, Game, GameView } from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
import { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-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 };
},
});
// Static UI imports would resolve before these Node-only Next/CSS hooks are
// installed; these imports intentionally exercise the platform loading boundary.
const { NexusIxianBetrayal } = await import('../components/nexus-ixian-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(NexusIxianBetrayal({ game, busy, act(value) { action = value; } }), `nexus-ixian-betrayal-${choice}`);
return action;
}
function markup(game: GameView): string {
return renderToStaticMarkup(createElement(NexusIxianBetrayal, { game, busy: false, act() {} }));
}
function table(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(GameTable, { game, busy, send: async () => {}, onExit() {} }));
}
function passAll(original: Game): Game {
let game = structuredClone(original);
for (const seat of game.players) {
const action = select(viewGame(game, seat.id), 'pass');
if (action) game = applyAction(game, seat.id, action);
}
return game;
}
const scenarios = [
{ kind: 'bidding', advanced: false },
{ kind: 'bidding', advanced: true },
{ kind: 'technology', advanced: true },
] as const;
for (const scenario of scenarios) {
void test(`${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.kind} human Use closes exactly the native advantage; Pass resumes it`, () => {
const fixture = createIxianNexusBetrayalFixture(scenario);
const own = viewGame(fixture.game, fixture.holder);
const action = select(own, 'use');
assert.deepEqual(action, { type: 'nexusIxianBetrayalUse', event: fixture.event });
assert.ok(action);
const denied = applyAction(fixture.game, fixture.holder, action);
const allowed = passAll(fixture.game);
assert.equal(viewGame(denied, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(viewGame(allowed, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(denied.nexusCards!.cards!.hands[fixture.holder], null);
assert.equal(allowed.nexusCards!.cards!.hands[fixture.holder], 'ixians');
assert.deepEqual(denied.players.map(seat => ({ id: seat.id, spice: seat.spice, forces: seat.forces, reserves: seat.reserves })),
allowed.players.map(seat => ({ id: seat.id, spice: seat.spice, forces: seat.forces, reserves: seat.reserves })));
if (scenario.kind === 'bidding') {
assert.equal(denied.ixAuction, null);
assert.equal(denied.auction!.cards.length, fixture.nativeCount);
assert.equal(allowed.decision?.kind, 'ixAuction');
assert.equal(allowed.ixAuction!.cards.length, fixture.nativeCount + 1);
} else {
assert.ok(fixture.selected);
const nativeBefore = fixture.game.players.find(seat => seat.id === fixture.provider)!;
assert.deepEqual(denied.players.find(seat => seat.id === fixture.provider)!.hand, nativeBefore.hand);
assert.deepEqual(denied.auction!.cards, fixture.game.auction!.cards);
assert.equal(allowed.players.find(seat => seat.id === fixture.provider)!.hand.some(card => card.id === fixture.selected!.id), false);
assert.equal(allowed.auction!.cards[allowed.auction!.index].id, fixture.selected.id);
assert.equal(denied.ixTechnologyTurn, fixture.game.turn);
assert.equal(allowed.ixTechnologyTurn, fixture.game.turn);
}
assert.equal(select(viewGame(denied, fixture.holder), 'use'), undefined);
assert.doesNotMatch(table(viewGame(denied, fixture.holder)), /data-testid="nexus-ixian-betrayal"/);
const before = JSON.stringify(denied);
assert.throws(() => applyAction(denied, fixture.holder, action));
assert.equal(JSON.stringify(denied), before);
});
}
void test('irrelevant held faces receive the same public acknowledgement without a private Ixian inspector or rival Use', () => {
for (const scenario of scenarios) {
const fixture = createIxianNexusBetrayalFixture(scenario);
const irrelevant = createIxianNexusBetrayalFixture({ ...scenario, face: 'richese' });
const own = viewGame(fixture.game, fixture.holder);
const neutral = viewGame(irrelevant.game, irrelevant.holder);
assert.match(markup(own), /aria-label="Inspect Ixians Nexus card"/);
assert.ok(select(own, 'use'));
assert.ok(select(neutral, 'pass'));
assert.equal(select(neutral, 'use'), undefined);
assert.doesNotMatch(markup(neutral), /Inspect Ixians Nexus card|Betrayal timing and preview limits/);
for (const seat of fixture.game.players.filter(seat => seat.id !== fixture.holder)) {
const rival = viewGame(fixture.game, seat.id);
assert.equal(select(rival, 'use'), undefined);
assert.doesNotMatch(markup(rival), /Inspect Ixians Nexus card/);
assert.equal(rival.nexusIxianBetrayalReaction!.blocked, null);
}
const publicOffer = (view: GameView) => {
const reaction = view.nexusIxianBetrayalReaction!;
return { event: reaction.event, kind: reaction.kind, provider: reaction.provider, canPass: reaction.canPass, hasPassed: reaction.hasPassed };
};
assert.deepEqual(publicOffer(own), publicOffer(neutral));
assert.doesNotMatch(markup(own), /Inspect Treachery|Your purchased Treachery|private auction card/);
}
});
void test('multiple public responders have no owner until only one acknowledgement remains', () => {
const fixture = createIxianNexusBetrayalFixture({ receiverCount: 2 });
for (const seat of fixture.game.players) assert.equal(tableActionOwner(viewGame(fixture.game, seat.id)), null);
const first = fixture.required[0];
const action = select(viewGame(fixture.game, first), 'pass');
assert.ok(action);
const next = applyAction(fixture.game, first, action);
for (const seat of next.players) assert.equal(tableActionOwner(viewGame(next, seat.id)), fixture.required[1]);
assert.equal(select(viewGame(next, first), 'pass'), undefined);
assert.equal(select(viewGame(next, first), 'use'), undefined);
});
void test('actual table reserves bids and voluntary card mutations; transport, room and autopilot locks stop response dispatch', () => {
for (const scenario of scenarios) {
const fixture = createIxianNexusBetrayalFixture(scenario);
const own: GameView = viewGame(fixture.game, fixture.holder);
const html = table(own);
assert.match(html, /data-testid="nexus-ixian-betrayal"/);
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Bid |Place bid|Play Karama|Make a bribe|Pledge|Search)/i);
for (const restriction of ['paused', 'closed', 'autopilot', 'busy'] as const) {
const blocked = structuredClone(own);
if (restriction === 'autopilot') blocked.players.find(seat => seat.id === own.me)!.autopilot = 'Easy';
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.nexusIxianBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
assert.equal(select(blocked, 'use'), undefined);
assert.ok(select(blocked, 'pass'));
const native = viewGame(fixture.beforeNativeCounter, fixture.holder);
assert.equal(native.nexusIxianBetrayalReaction, null);
assert.doesNotMatch(table(native), /data-testid="nexus-ixian-betrayal"/);
assert.match(table(native), /Power response/);
}
});
void test('every seat can regain independent control while the native acknowledgement remains unchanged', () => {
const fixture = createIxianNexusBetrayalFixture({ receiverCount: 2 });
for (const seat of fixture.game.players) {
const delegated = applyAction(fixture.game, seat.id, { type: 'setAutopilot', difficulty: 'Hard' });
const view = viewGame(delegated, seat.id);
const takeBackButton = [...table(view).matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)]
.map(match => match[0]).find(button => button.includes('Take back control'));
assert.ok(takeBackButton);
assert.doesNotMatch(takeBackButton, /disabled=/);
const resumed = applyAction(delegated, seat.id, { type: 'setAutopilot', difficulty: null });
assert.equal(resumed.players.find(player => player.id === seat.id)!.autopilot, undefined);
assert.deepEqual(resumed.pendingNexusIxianBetrayal, fixture.game.pendingNexusIxianBetrayal);
assert.deepEqual(resumed.deck, fixture.game.deck);
}
});
aliases.deregister();
+567
View File
@@ -0,0 +1,567 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { 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 { unitStore } from './fixture-nexus-room-store';
import {
createIxianNexusBetrayalFixture,
type IxianBetrayalFixture,
type IxianBetrayalFixtureOptions as Options,
} from './fixture-nexus-ixian-betrayal';
type SqlRow = Record<string, SQLOutputValue>;
interface Fixture extends IxianBetrayalFixture {
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
writes: { expected: number; changes: number }[];
code: string;
tokens: string[];
auths: SeatAuth[];
save(game: Game): void;
protectedCredentials: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; receipts: SqlRow[] };
unrelatedCode: string;
unrelatedToken: string;
unrelatedRow: SqlRow | undefined;
stock: string[];
initial: Game;
}
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
function credentials(sqlite: DatabaseSync) {
return {
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
receipts: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
};
}
function totalChanges(sqlite: DatabaseSync) {
const value = sqlite.prepare('SELECT total_changes() AS changes').get()?.changes;
assert.equal(typeof value, 'number');
return value;
}
function physical(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(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, 'one physical custodian per native Treachery card');
return ids;
}
async function fixture(t: test.TestContext, options: Options & { bgCounter?: true } = {}): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Native Ixian recovery', 'ixians', options.advanced ?? true, ['ix']);
const code = made.view.code;
const tokens = [made.token];
for (const faction of options.receiverCount === 2 ? ['atreides', 'emperor', 'guild'] as const
: options.bgCounter ? ['atreides', 'beneGesserit'] as const : ['atreides', 'emperor'] as const) {
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)));
const admitted = await store.rooms.readRoom(code);
const native = createIxianNexusBetrayalFixture({ ...options, initial: admitted });
const parent = native.beforeNativeAttempt;
assert.equal(parent.code, admitted.code);
assert.equal(parent.host, admitted.host);
assert.equal(parent.advanced, admitted.advanced);
assert.deepEqual(parent.expansions, admitted.expansions);
assert.deepEqual(parent.playerPositions, admitted.playerPositions);
assert.deepEqual(parent.players.map(p => [p.id, p.name, p.faction]), admitted.players.map(p => [p.id, p.name, p.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(parent);
const unrelated = await store.rooms.createRoom('Unrelated preserved lobby', 'guild', false, []);
const protectedCredentials = credentials(store.sqlite);
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(unrelated.view.code);
const f: Fixture = { ...store, ...native, code, tokens, auths, save,
protectedCredentials, unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token,
unrelatedRow, stock: physical(parent), initial: parent };
f.initial = await openGate(f, parent);
assert.equal(viewGame(f.initial, native.holder).nexusIxianBetrayalReaction?.event, native.event);
store.writes.length = 0;
return f;
}
function protectedRows(f: Fixture) {
assert.deepEqual(credentials(f.sqlite), f.protectedCredentials);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.unrelatedCode), f.unrelatedRow);
}
function inventory(f: Fixture, game: Game) {
assert.deepEqual(physical(game), f.stock);
assert.equal(f.stock.length, 47);
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());
protectedRows(f);
}
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);
return f.restart().readRoom(f.code);
}
function unchangedHistory(f: Fixture, game: Game) {
assert.deepEqual(game.nexusIxianBetrayalHistory, f.beforeNativeAttempt.nexusIxianBetrayalHistory);
assert.deepEqual(game.nexusIxianBetrayalCursor, f.beforeNativeAttempt.nexusIxianBetrayalCursor);
}
function closedHistory(f: Fixture, game: Game, used: boolean, gate = f.initial) {
const prior = f.beforeNativeAttempt.nexusIxianBetrayalHistory!;
const cursor = f.beforeNativeAttempt.nexusIxianBetrayalCursor!;
const history = game.nexusIxianBetrayalHistory!;
assert.equal(history.length, prior.length + 1);
assert.deepEqual(history.slice(0, prior.length), prior);
const receipt = history[prior.length];
assert.deepEqual(receipt.source, gate.pendingNexusIxianBetrayal!.source);
assert.equal(receipt.source.event, f.event);
assert.equal(receipt.source.sequence, cursor.sequence);
assert.equal(receipt.outcome, used ? 'use' : 'pass');
assert.equal(receipt.holder, used ? f.holder : null);
assert.equal(receipt.previous, cursor.signature);
assert.equal(game.nexusIxianBetrayalCursor!.sequence, cursor.sequence + 1);
assert.notEqual(game.nexusIxianBetrayalCursor!.signature, cursor.signature);
}
async function openGate(f: Fixture, parent: Game) {
let game = await act(f, parent, f.actor, f.declarationAction);
assert.ok(game.response, 'the original native Karama counter window opens first');
assert.equal(viewGame(game, f.holder).nexusIxianBetrayalReaction, null);
for (let step = 0; game.response && step < 20; step++) {
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
assert.ok(responder);
game = await act(f, game, responder.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
unchangedHistory(f, game);
assert.ok(viewGame(game, f.holder).nexusIxianBetrayalReaction);
return game;
}
async function rejected(f: Fixture, game: Game, auth: SeatAuth, action: Action, version = game.version) {
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const changes = totalChanges(f.sqlite);
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.equal(totalChanges(f.sqlite), changes, 'rejected action writes no durable resource table');
protectedRows(f);
}
async function restored(f: Fixture, game: Game) {
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const changes = totalChanges(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)));
assert.equal(view.nexusIxianBetrayalPreview, true);
for (const key of ['pendingNexusIxianBetrayal', 'nexusIxianBetrayalHistory', 'nexusIxianBetrayalCursor'])
assert.equal(Object.hasOwn(view, key), false);
for (const rival of view.players.filter(p => p.id !== auth.playerId))
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
const reaction = view.nexusIxianBetrayalReaction;
if (reaction) {
assert.deepEqual(Object.keys(reaction).sort(), ['event', 'kind', 'provider', 'canPass', 'hasPassed', 'canUse', 'blocked'].sort());
assert.equal(reaction.event, f.event);
assert.equal(reaction.kind, f.kind);
assert.equal(reaction.provider, f.provider);
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(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.equal(totalChanges(f.sqlite), changes, 'refresh cannot autoallow or consume the original attempt');
inventory(f, game);
}
async function closeGate(f: Fixture, game: Game, used: boolean) {
if (used) return act(f, game, f.holder, { type: 'nexusIxianBetrayalUse', event: f.event });
let next = game;
for (const id of f.required)
next = await act(f, next, id, { type: 'nexusIxianBetrayalPass', event: f.event });
return next;
}
function settled(f: Fixture, game: Game, used: boolean) {
assert.equal(game.pendingNexusIxianBetrayal ?? null, null);
assert.equal(viewGame(game, f.holder).nexusIxianBetrayalReaction, null);
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 ? ['ixians'] : [])]);
assert.deepEqual(game.players.map(p => [p.id, p.spice, p.reserves, p.forces]),
f.initial.players.map(p => [p.id, p.spice, p.reserves, p.forces]), 'Nexus costs no spice or forces');
assert.deepEqual(game.discard, f.initial.discard, 'no Treachery card pays this reaction');
const native = game.players.find(p => p.id === f.provider)!;
const previous = f.initial.players.find(p => p.id === f.provider)!;
if (f.kind === 'bidding') {
assert.equal(game.deck.length, f.initial.deck.length - f.nativeCount - Number(!used));
assert.equal(game.ixAuction?.cards.length ?? 0, used ? 0 : f.nativeCount + 1);
assert.equal(game.auction?.cards.length ?? 0, used ? f.nativeCount : 0);
assert.deepEqual(native.hand, previous.hand);
} else {
assert.ok(f.selected);
assert.equal(game.pendingIxTechnology ?? null, null);
assert.equal(game.ixTechnologyTurn, game.turn, 'the accepted once-per-turn attempt remains spent');
assert.equal(game.auction!.index, f.initial.auction!.index);
assert.equal(game.auction!.bid, f.initial.auction!.bid);
assert.equal(game.auction!.bidder, f.initial.auction!.bidder);
const originalLot = f.initial.auction!.cards[f.initial.auction!.index];
assert.equal(native.hand.some(card => card.id === f.selected!.id), used);
assert.equal(native.hand.some(card => card.id === originalLot.id), !used);
assert.equal(game.auction!.cards[game.auction!.index].id, used ? originalLot.id : f.selected.id);
assert.equal(game.deck.length, f.initial.deck.length);
}
closedHistory(f, game, used);
inventory(f, game);
}
for (const [kind, advanced] of [['bidding', false], ['bidding', true], ['technology', true]] as const) {
for (const used of [false, true]) {
void test(`SQLite actual ${advanced ? 'Advanced' : 'Basic'} native ${kind} ${used ? 'prevent' : 'allow'} restores and resumes once`, async t => {
const f = await fixture(t, { kind, advanced });
await restored(f, f.initial);
const done = await closeGate(f, f.initial, used);
assert.equal(done.version, f.initial.version + 1);
settled(f, done, used);
await restored(f, done);
for (const type of ['nexusIxianBetrayalPass', 'nexusIxianBetrayalUse'] as const)
await rejected(f, done, await authenticate(f, f.holder), { type, event: f.event });
});
}
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`SQLite ${kind} stale, foreign, wrong role, unknown fields and revoked credentials cost nothing`, async t => {
const f = await fixture(t, { kind });
const holder = await authenticate(f, f.holder);
const provider = await authenticate(f, f.provider);
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const changes = totalChanges(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.equal(totalChanges(f.sqlite), changes);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
const use: Action = { type: 'nexusIxianBetrayalUse', event: f.event };
await rejected(f, f.initial, holder, use, f.initial.version - 1);
await rejected(f, f.initial, foreign, use);
await rejected(f, f.initial, provider, use);
await rejected(f, f.initial, provider, { type: 'nexusIxianBetrayalPass', event: f.event });
for (const action of [{ ...use, event: `${f.event}:expired` }, { ...use, kind },
{ ...use, provider: f.provider }, { ...use, card: f.initial.deck[0].id }, { ...use, price: 0 }])
await rejected(f, f.initial, holder, action as Action);
for (const action of [
{ type: 'passBid' }, { type: 'passResponse' }, { type: 'ready' },
{ type: 'decision', card: f.initial.players.find(p => p.id === f.provider)!.hand[0].id },
])
await rejected(f, f.initial, provider, action as Action);
await restored(f, f.initial);
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
f.protectedCredentials = credentials(f.sqlite);
const changesAfterRevocation = totalChanges(f.sqlite);
await assert.rejects(f.restart().authenticate(f.code, f.tokens[f.auths.findIndex(auth => auth.playerId === f.holder)]));
assert.equal(totalChanges(f.sqlite), changesAfterRevocation);
await assert.rejects(f.restart().readSeatView(f.code, holder));
await rejected(f, f.initial, holder, use);
await rejected(f, f.initial, holder, { type: 'nexusIxianBetrayalPass', event: f.event });
});
void test(`SQLite competing ${kind} Pass/Use commits one native continuation`, async t => {
const f = await fixture(t, { kind });
assert.equal(f.required.length, 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; };
let results: PromiseSettledResult<unknown>[];
try {
const auth = await authenticate(f, f.holder);
results = await Promise.allSettled(['nexusIxianBetrayalPass', 'nexusIxianBetrayalUse'].map(type =>
f.restart().act(f.code, auth, f.initial.version, { type, event: f.event } as Action, 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);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, f.initial.version + 1);
settled(f, done, results[1].status === 'fulfilled');
await restored(f, done);
});
void test(`SQLite ${kind} revocation at final CAS preserves the pending native source`, async t => {
const f = await fixture(t, { kind });
const auth = await authenticate(f, f.holder);
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const changes = totalChanges(f.sqlite);
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.protectedCredentials = credentials(f.sqlite);
};
try {
await assert.rejects(f.restart().act(f.code, auth, f.initial.version,
{ type: 'nexusIxianBetrayalUse', event: f.event }, clock));
} finally { delete f.hooks.beforeWrite; }
assert.equal(revoked, true);
assert.equal(totalChanges(f.sqlite), Number(changes) + 1, 'only explicit revocation is durable');
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));
});
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`SQLite original ${kind} printed Karama preempts Nexus without spending its face or opening a gate`, async t => {
const f = await fixture(t, { kind });
f.save(f.beforeNativeAttempt);
const declared = await act(f, f.beforeNativeAttempt, f.actor, f.declarationAction);
const counter = declared.players.find(p => p.id !== f.provider && p.hand.some(card => card.effect === 'karama'));
assert.ok(counter);
const karama = counter.hand.find(card => card.effect === 'karama')!;
let game = await act(f, declared, counter.id, { type: 'card', mode: 'cancel', card: karama.id });
for (let step = 0; game.response && step < 20; step++) {
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
assert.ok(responder);
game = await act(f, game, responder.id, { type: 'passResponse' });
}
assert.equal(viewGame(game, f.holder).nexusIxianBetrayalReaction, null);
unchangedHistory(f, game);
assert.deepEqual(game.nexusCards!.cards, f.beforeNativeAttempt.nexusCards!.cards);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
if (kind === 'bidding') {
assert.equal(game.deck.length, declared.deck.length - f.nativeCount);
assert.equal(game.ixAuction ?? null, null);
assert.equal(game.auction!.cards.length, f.nativeCount);
} else {
assert.equal(game.ixTechnologyTurn, game.turn);
assert.equal(game.pendingIxTechnology ?? null, null);
assert.deepEqual(game.players.find(p => p.id === f.provider)!.hand,
declared.players.find(p => p.id === f.provider)!.hand);
assert.equal(game.auction!.cards[game.auction!.index].id, declared.auction!.cards[declared.auction!.index].id);
}
await restored(f, game);
});
void test(`SQLite ${kind} relevant and irrelevant owned faces expose identical rival acknowledgments`, async t => {
const f = await fixture(t, { kind });
// Vary only owned physical custody before the original native declaration.
// Do not re-sign an already authorized source or manufacture another hand.
const parent = structuredClone(f.beforeNativeAttempt);
const cards = parent.nexusCards!.cards!;
const index = cards.deck.indexOf('richese');
assert.ok(index >= 0);
assert.equal(cards.hands[f.holder], 'ixians');
cards.hands[f.holder] = 'richese';
cards.deck[index] = 'ixians';
f.save(parent);
const irrelevant = await openGate(f, parent);
assert.equal(viewGame(irrelevant, f.holder).nexusIxianBetrayalReaction!.event, f.event);
await restored(f, 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) assert.deepEqual(plain(changed), plain(original));
else {
assert.equal(changed.nexusIxianBetrayalReaction!.canPass, true);
assert.equal(changed.nexusIxianBetrayalReaction!.canUse, false);
}
}
await rejected(f, irrelevant, await authenticate(f, f.holder), { type: 'nexusIxianBetrayalUse', event: f.event });
const done = await closeGate(f, irrelevant, false);
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
assert.deepEqual(done.nexusCards!.cards!.discard, irrelevant.nexusCards!.cards!.discard);
closedHistory(f, done, false, irrelevant);
await restored(f, done);
});
void test(`SQLite ${kind} pending source, native custody, cursor and profile corruption never heals or writes`, async t => {
const f = await fixture(t, { kind });
const changes: { name: string; change: (game: Game) => void }[] = [
{ name: 'missing source', change: game => { delete game.pendingNexusIxianBetrayal; } },
{ name: 'missing cursor', change: game => { delete game.nexusIxianBetrayalCursor; } },
{ name: 'missing history', change: game => { delete game.nexusIxianBetrayalHistory; } },
{ name: 'cursor sequence', change: game => { game.nexusIxianBetrayalCursor!.sequence++; } },
{ name: 'parent link', change: game => { game.pendingNexusIxianBetrayal!.source.parent += ':forged'; } },
{ name: 'native source', change: game => { game.pendingNexusIxianBetrayal!.source.nativeContext += ':forged'; } },
{ name: 'native provider', change: game => { game.pendingNexusIxianBetrayal!.source.provider = f.holder; } },
{ name: 'native kind', change: game => { game.pendingNexusIxianBetrayal!.source.kind = kind === 'bidding' ? 'technology' : 'bidding'; } },
{ name: 'missing native passes', change: game => { game.pendingNexusIxianBetrayal!.source.nativePassed = []; } },
{ name: 'native continuation', change: game => { game.pendingNexusIxianBetrayal!.continuation.owner = f.holder; } },
{ name: 'required membership', change: game => { game.pendingNexusIxianBetrayal!.required = [f.provider]; } },
{ name: 'invented pass', change: game => { game.pendingNexusIxianBetrayal!.passed = [f.provider]; } },
{ name: 'lost profile', change: game => { delete game.nexusIxianBetrayalPreview; } },
{ name: 'native roster', change: game => { game.players.find(p => p.id === f.provider)!.faction = 'guild'; } },
{ name: 'foreign expansion', change: game => { game.expansions.push('choam'); } },
{ name: 'wrong phase', change: game => { game.phase++; } },
{ name: 'wrong turn', change: game => { game.turn++; } },
{ name: 'duplicate Nx stock', change: game => { game.nexusCards!.cards!.deck.push('ixians'); } },
{ name: 'missing Treachery stock', change: game => { game.deck.pop(); } },
{ name: 'canonical Treachery face', change: game => { game.deck[0].name += ' forged'; } },
...(kind === 'technology' ? [
{ name: 'accepted native attempt', change: (game: Game) => { delete game.ixTechnologyTurn; } },
{ name: 'selected native card', change: (game: Game) => { game.pendingIxTechnology!.card = game.deck[0].id; } },
] : [{ name: 'native draw count', change: (game: Game) => { game.ixAuction!.count++; } }]),
];
for (const { name, change } of changes) {
const corrupt = structuredClone(f.initial);
change(corrupt);
f.save(corrupt);
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const writes = totalChanges(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 ['nexusIxianBetrayalPass', 'nexusIxianBetrayalUse'] as const)
await rejected(f, corrupt, await authenticate(f, f.holder), { type, event: f.event });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before, name);
assert.equal(totalChanges(f.sqlite), writes, name);
assert.deepEqual(corrupt, original);
protectedRows(f);
}
});
void test(`SQLite ${kind} two genuine unallied receivers restore partial pass and reject replay`, async t => {
const f = await fixture(t, { kind, receiverCount: 2 });
assert.equal(f.initial.players.length, 4);
assert.equal(f.required.length, 2);
const irrelevant = f.required.find(id => id !== f.holder)!;
const first = await act(f, f.initial, irrelevant, { type: 'nexusIxianBetrayalPass', event: f.event });
assert.ok(first.pendingNexusIxianBetrayal);
assert.equal(viewGame(first, irrelevant).nexusIxianBetrayalReaction!.hasPassed, true);
unchangedHistory(f, first);
assert.deepEqual(first.nexusCards!.cards, f.initial.nexusCards!.cards);
await restored(f, first);
await rejected(f, first, await authenticate(f, irrelevant), { type: 'nexusIxianBetrayalPass', event: f.event });
const done = await act(f, first, f.holder, { type: 'nexusIxianBetrayalUse', event: f.event });
settled(f, done, true);
await restored(f, done);
});
for (const used of [false, true]) {
void test(`SQLite ${kind} ${used ? 'use' : 'pass'} history survives later physical recycling and rejects corrupted links`, async t => {
const f = await fixture(t, { kind });
const done = await closeGate(f, f.initial, used);
settled(f, done, used);
await restored(f, done);
const later = structuredClone(done);
if (used) {
const cards = later.nexusCards!.cards!;
const index = cards.discard.indexOf('ixians');
assert.ok(index >= 0);
cards.deck.push(...cards.discard.splice(index, 1));
}
// Preserve physical custody while representing later native card circulation.
const card = later.deck.pop()!;
later.discard.push(card);
f.save(later);
await restored(f, later);
assert.deepEqual(later.nexusIxianBetrayalHistory, done.nexusIxianBetrayalHistory);
assert.deepEqual(later.nexusIxianBetrayalCursor, done.nexusIxianBetrayalCursor);
for (const type of ['nexusIxianBetrayalPass', 'nexusIxianBetrayalUse'] as const)
await rejected(f, later, await authenticate(f, f.holder), { type, event: f.event });
for (const change of [
(game: Game) => { game.nexusIxianBetrayalHistory = []; },
(game: Game) => { delete game.nexusIxianBetrayalCursor; },
(game: Game) => { game.nexusIxianBetrayalCursor!.signature += ':forged'; },
...done.nexusIxianBetrayalHistory!.flatMap((_receipt, index) => [
(game: Game) => { game.nexusIxianBetrayalHistory![index].signature += ':forged'; },
(game: Game) => { game.nexusIxianBetrayalHistory![index].source.parent += ':forged'; },
(game: Game) => { game.nexusIxianBetrayalHistory![index].source.nativeContext += ':forged'; },
]),
]) {
const corrupt = structuredClone(later);
change(corrupt);
f.save(corrupt);
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const writes = totalChanges(f.sqlite);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
await rejected(f, corrupt, await authenticate(f, f.holder), { type: 'nexusIxianBetrayalUse', event: f.event });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.equal(totalChanges(f.sqlite), writes);
}
});
}
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`SQLite Advanced BG conversion counters the actual ${kind} attempt before Nexus`, async t => {
const f = await fixture(t, { kind, advanced: true, bgCounter: true });
f.save(f.beforeNativeAttempt);
const declared = await act(f, f.beforeNativeAttempt, f.actor, f.declarationAction);
const counter = declared.players.find(p => p.faction === 'beneGesserit')!;
const card = counter.hand.find(value => value.effect !== 'karama');
assert.ok(card, 'genuine initial BG Treachery card fuels its native conversion');
let game = await act(f, declared, counter.id, { type: 'card', mode: 'cancel', card: card.id });
for (let step = 0; game.response && step < 20; step++) {
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
assert.ok(responder);
game = await act(f, game, responder.id, { type: 'passResponse' });
}
assert.deepEqual(game.nexusCards!.cards, f.beforeNativeAttempt.nexusCards!.cards);
unchangedHistory(f, game);
assert.equal(viewGame(game, f.holder).nexusIxianBetrayalReaction, null);
assert.equal(game.discard.filter(value => value.id === card.id).length, 1);
if (kind === 'bidding') {
assert.equal(game.deck.length, declared.deck.length - f.nativeCount);
assert.equal(game.auction!.cards.length, f.nativeCount);
assert.equal(game.ixAuction ?? null, null);
} else {
assert.equal(game.ixTechnologyTurn, game.turn);
assert.equal(game.pendingIxTechnology ?? null, null);
assert.deepEqual(game.players.find(p => p.id === f.provider)!.hand,
declared.players.find(p => p.id === f.provider)!.hand);
assert.deepEqual(game.auction!.cards, declared.auction!.cards);
}
await restored(f, game);
});
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`SQLite ${kind} racing two receiver passes retains the first acknowledgment and resumes only after a fresh second CAS`, async t => {
const f = await fixture(t, { kind, receiverCount: 2 });
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const auths = await Promise.all(f.required.map(id => authenticate(f, id)));
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
let results: PromiseSettledResult<unknown>[];
try {
results = await Promise.allSettled(auths.map(auth =>
f.restart().act(f.code, auth, f.initial.version,
{ type: 'nexusIxianBetrayalPass', 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);
const first = await f.restart().readRoom(f.code);
const winner = f.required[results.findIndex(result => result.status === 'fulfilled')];
const loser = f.required[results.findIndex(result => result.status === 'rejected')];
assert.deepEqual(first.pendingNexusIxianBetrayal!.passed, [winner]);
assert.equal(first.version, f.initial.version + 1);
unchangedHistory(f, first);
assert.deepEqual(first.nexusCards!.cards, f.initial.nexusCards!.cards);
await restored(f, first);
await rejected(f, first, await authenticate(f, winner), { type: 'nexusIxianBetrayalPass', event: f.event });
const done = await act(f, first, loser, { type: 'nexusIxianBetrayalPass', event: f.event });
assert.equal(done.version, f.initial.version + 2);
settled(f, done, false);
await restored(f, done);
});
}
+225
View File
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction,createGame,initializeIxianNexusBetrayalGameForAudit,joinGame,newPlayer,normalizeAutomaticGame,viewGame,type Game,type Action} from '../game/engine';
import {createIxianNexusBetrayalFixture,settleIxianBetrayalNativeCounters} from './fixture-nexus-ixian-betrayal';
const reload=(g:Game):Game=>JSON.parse(JSON.stringify(g));
const owner=(g:Game,id:string)=>g.players.find(p=>p.id===id)!;
function passNexus(state:Game):Game {
let g=state;
while(g.pendingNexusIxianBetrayal) {const f=g.pendingNexusIxianBetrayal;g=applyAction(g,f.required.find(id=>!f.passed.includes(id))!,{type:'nexusIxianBetrayalPass',event:f.source.event});}
return g;
}
function reject(g:Game,id:string,a:Action):void {const before=structuredClone(g);assert.throws(()=>applyAction(g,id,a));assert.deepEqual(g,before);}
const ledger=(g:Game)=>g.players.map(p=>({id:p.id,spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,hand:p.hand}));
for(const [kind,advanced] of [['bidding',false],['bidding',true],['technology',true]] as const) for(const use of [false,true]) {
void test(`${advanced?'Advanced':'Basic'} original ${kind} ${use?'prevention':'allowance'} resumes its native suffix once`,()=>{
const f=createIxianNexusBetrayalFixture({kind,advanced});
assert.deepEqual(settleIxianBetrayalNativeCounters(applyAction(f.beforeNativeAttempt,f.actor,f.declarationAction)),f.game);
const before=reload(f.game),nx=before.nexusCards!.cards!;
const g=use?applyAction(before,f.holder,{type:'nexusIxianBetrayalUse',event:f.event}):passNexus(before);
assert.equal(g.pendingNexusIxianBetrayal,null);
assert.equal(g.nexusIxianBetrayalHistory!.at(-1)!.outcome,use?'use':'pass');
assert.equal(g.nexusIxianBetrayalCursor!.sequence,before.nexusIxianBetrayalCursor!.sequence+1);
assert.deepEqual(g.discard,before.discard);
assert.deepEqual(g.players.map(p=>({spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites})),before.players.map(p=>({spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites})));
assert.equal(g.nexusCards!.cards!.discard.length,nx.discard.length+(use?1:0));
assert.equal(g.nexusCards!.cards!.hands[f.holder],use?null:'ixians');
if(kind==='bidding') {
assert.deepEqual(ledger(g),ledger(before));
assert.equal(before.deck.length-g.deck.length,f.nativeCount+(use?0:1));
if(use) {assert.equal(g.ixAuction,null);assert.equal(g.auction!.cards.length,f.nativeCount);assert.notEqual(g.decision?.kind,'ixAuction');}
else {assert.equal(g.decision?.kind,'ixAuction');assert.equal(g.ixAuction!.cards.length,f.nativeCount+1);assert.equal(g.auction,null);}
} else {
assert.equal(g.ixTechnologyTurn,g.turn);assert.equal(g.pendingIxTechnology,null);
assert.equal(g.auction!.index,before.auction!.index);
assert.equal(g.auction!.bid,before.auction!.bid);assert.equal(g.auction!.bidder,before.auction!.bidder);
const original=before.auction!.cards[before.auction!.index];
const expectedCards=[...before.auction!.cards];
if(!use) expectedCards[before.auction!.index]=f.selected!;
assert.deepEqual(g.auction!.cards,expectedCards);
if(use) assert.deepEqual(owner(g,f.provider).hand,owner(before,f.provider).hand);
else {assert.ok(owner(g,f.provider).hand.some(c=>c.id===original.id));assert.ok(!owner(g,f.provider).hand.some(c=>c.id===f.selected!.id));}
assert.deepEqual(g.deck,before.deck);
const atreides=before.players.find(p=>p.faction==='atreides')!.id;
assert.equal(viewGame(before,atreides).auction!.card,null,'the native peek cannot precede the Nexus acknowledgment');
const settled=settleIxianBetrayalNativeCounters(g);
assert.equal(settled.auction!.peekKnown,true);
assert.equal(settled.response,null);
assert.deepEqual(viewGame(settled,atreides).auction!.card,use?original:f.selected);
for(const rival of settled.players.filter(p=>p.id!==atreides))
assert.equal(viewGame(settled,rival.id).auction!.card,null);
assert.deepEqual(settled.auction!.cards,expectedCards);
assert.equal(settled.auction!.index,before.auction!.index);
assert.deepEqual(ledger(settled),ledger(g));
assert.deepEqual(settled.deck,g.deck);
assert.deepEqual(settled.discard,g.discard);
assert.deepEqual(settled.nexusCards,g.nexusCards);
assert.deepEqual(settled.nexusIxianBetrayalHistory,g.nexusIxianBetrayalHistory);
assert.deepEqual(settleIxianBetrayalNativeCounters(settled),settled);
}
reject(g,f.holder,{type:'nexusIxianBetrayalUse',event:f.event});
reject(g,f.holder,{type:'nexusIxianBetrayalPass',event:f.event});
viewGame(reload(g),f.holder);
});
}
for(const kind of ['bidding','technology'] as const) void test(`printed native Karama preempts ${kind} with no Nexus gate or cost`,()=>{
const f=createIxianNexusBetrayalFixture({kind,advanced:true}),before=f.beforeNativeCounter;
const seat=before.players.find(p=>p.id!==f.provider&&p.hand.some(c=>c.effect==='karama'))!;
assert.ok(seat);
const card=seat.hand.find(c=>c.effect==='karama')!;
const g=applyAction(before,seat.id,{type:'card',mode:'cancel',card:card.id});
assert.equal(g.pendingNexusIxianBetrayal,null);
assert.deepEqual(g.nexusCards,before.nexusCards);
assert.deepEqual(g.nexusIxianBetrayalHistory,before.nexusIxianBetrayalHistory);
assert.ok(g.discard.some(c=>c.id===card.id));
if(kind==='bidding') {assert.equal(g.ixAuction,null);assert.equal(g.auction!.cards.length,f.nativeCount);}
else {assert.equal(g.pendingIxTechnology,null);assert.equal(g.ixTechnologyTurn,g.turn);assert.deepEqual(owner(g,f.provider).hand,owner(before,f.provider).hand);assert.deepEqual(g.auction!.cards,before.auction!.cards);}
});
for(const kind of ['bidding','technology'] as const) void test(`native BG Worthless Karama suspends and cancels ${kind} before any Nexus acknowledgment`,()=>{
const lobby=createGame('BGCOUNTER',newPlayer('ix','Ixians','ixians'),true,['ix']);
joinGame(lobby,newPlayer('a','Atreides','atreides'));
joinGame(lobby,newPlayer('bg','Bene Gesserit','beneGesserit'));
joinGame(lobby,newPlayer('tl','Tleilaxu','tleilaxu'));
const f=createIxianNexusBetrayalFixture({initial:lobby,kind,receiverCount:2});
const before=f.beforeNativeCounter,fuel=owner(before,'bg').hand.find(c=>c.kind==='worthless')!;
assert.ok(fuel);
let g=applyAction(before,'bg',{type:'card',mode:'cancel',card:fuel.id});
assert.equal(g.response?.kind,'worthlessKarama');
assert.equal(g.pendingKarama?.use.kind,'cancel');
for(const seat of g.players) {
assert.equal(viewGame(g,seat.id).nexusIxianBetrayalReaction,null);
assert.equal(viewGame(g,seat.id).auction?.card??null,null);
}
g=settleIxianBetrayalNativeCounters(reload(g));
assert.equal(g.pendingKarama,null);
assert.equal(g.pendingNexusIxianBetrayal,null);
assert.deepEqual(g.nexusCards,before.nexusCards);
assert.deepEqual(g.nexusIxianBetrayalHistory,before.nexusIxianBetrayalHistory);
assert.deepEqual(g.nexusIxianBetrayalCursor,before.nexusIxianBetrayalCursor);
assert.equal(g.discard.filter(c=>c.id===fuel.id).length,1);
if(kind==='bidding') {
assert.equal(g.ixAuction,null);
assert.equal(g.auction!.cards.length,f.nativeCount);
assert.equal(before.deck.length-g.deck.length,f.nativeCount);
} else {
assert.equal(g.pendingIxTechnology,null);
assert.equal(g.ixTechnologyTurn,g.turn);
assert.deepEqual(g.auction!.cards,before.auction!.cards);
assert.deepEqual(owner(g,f.provider).hand,owner(before,f.provider).hand);
assert.deepEqual(g.deck,before.deck);
assert.equal(viewGame(g,'a').auction!.card!.id,before.auction!.cards[before.auction!.index].id);
}
});
for(const returnDeclared of [false,true]) void test(`native Harkonnen exchange ${returnDeclared?'restores':'removes'} a paused declared Technology card without stranding its original continuation`,()=>{
const lobby=createGame('HARKCOUNTER',newPlayer('h','Harkonnen','harkonnen'),true,['ix']);
joinGame(lobby,newPlayer('ix','Ixians','ixians'));
joinGame(lobby,newPlayer('a','Atreides','atreides'));
joinGame(lobby,newPlayer('g','Guild','guild'));
const f=createIxianNexusBetrayalFixture({initial:lobby,kind:'technology'});
const before=f.beforeNativeCounter,karama=owner(before,'h').hand.find(c=>c.effect==='karama')!;
const returned=owner(before,'h').hand.find(c=>c.id!==karama?.id)!;
assert.ok(karama);assert.ok(returned);
assert.equal(owner(before,f.provider).hand.length,1);
const original=before.auction!.cards[before.auction!.index],declared=f.selected!;
let g=applyAction(before,'h',{type:'card',mode:'special',card:karama.id,target:f.provider,amount:1});
assert.equal(g.decision?.kind,'handExchange');
assert.equal(g.pendingExchange!.response!.kind,'ixTechnology');
assert.ok(owner(g,'h').hand.some(c=>c.id===declared.id));
for(const seat of g.players) assert.equal(viewGame(g,seat.id).nexusIxianBetrayalReaction,null);
const orphan=reload(g);
orphan.pendingExchange!.response!.owner=f.holder;
assert.throws(()=>viewGame(orphan,f.holder));
assert.throws(()=>normalizeAutomaticGame(orphan));
reject(orphan,'h',{type:'decision',returnCards:[returned.id]});
g=applyAction(reload(g),'h',{type:'decision',returnCards:[returnDeclared?declared.id:returned.id]});
g=settleIxianBetrayalNativeCounters(g);
assert.deepEqual(g.nexusCards,before.nexusCards);
assert.deepEqual(g.deck,before.deck);
if(returnDeclared) {
assert.equal(g.pendingNexusIxianBetrayal!.source.kind,'technology');
assert.equal(viewGame(g,'a').auction!.card,null);
g=settleIxianBetrayalNativeCounters(passNexus(g));
assert.equal(g.auction!.cards[g.auction!.index].id,declared.id);
assert.equal(owner(g,f.provider).hand[0].id,original.id);
assert.equal(g.nexusIxianBetrayalCursor!.sequence,before.nexusIxianBetrayalCursor!.sequence+1);
} else {
assert.equal(g.pendingNexusIxianBetrayal,null);
assert.deepEqual(g.nexusIxianBetrayalHistory,before.nexusIxianBetrayalHistory);
assert.deepEqual(g.nexusIxianBetrayalCursor,before.nexusIxianBetrayalCursor);
assert.deepEqual(g.auction!.cards,before.auction!.cards);
assert.equal(owner(g,f.provider).hand[0].id,returned.id);
assert.ok(owner(g,'h').hand.some(c=>c.id===declared.id));
}
assert.equal(g.pendingExchange,null);
assert.equal(g.pendingIxTechnology,null);
assert.equal(g.ixTechnologyTurn,g.turn);
assert.equal(g.discard.filter(c=>c.id===karama.id).length,1);
assert.equal(g.auction!.peekKnown,true);
assert.equal(viewGame(g,'a').auction!.card!.id,returnDeclared?declared.id:original.id);
assert.deepEqual(settleIxianBetrayalNativeCounters(g),g);
});
void test('neutral required membership, public source and rivals are independent of held face',()=>{
const ix=createIxianNexusBetrayalFixture({receiverCount:2}),irrelevant=createIxianNexusBetrayalFixture({receiverCount:2,face:'richese'});
assert.deepEqual(ix.required,irrelevant.required);assert.equal(ix.required.length,2);
for(const id of ix.game.players.filter(p=>!ix.required.includes(p.id)).map(p=>p.id)) {
assert.deepEqual(viewGame(ix.game,id).nexusIxianBetrayalReaction,viewGame(irrelevant.game,id).nexusIxianBetrayalReaction);
const text=JSON.stringify(viewGame(ix.game,id));
assert.ok(!text.includes(ix.game.pendingNexusIxianBetrayal!.source.parent));
assert.ok(!text.includes('nativeContext'));
}
assert.equal(viewGame(ix.game,ix.holder).nexusIxianBetrayalReaction!.canUse,true);
assert.equal(viewGame(irrelevant.game,irrelevant.holder).nexusIxianBetrayalReaction!.canUse,false);
assert.equal(viewGame(ix.game,ix.holder).active,null);
const passed=applyAction(ix.game,ix.holder,{type:'nexusIxianBetrayalPass',event:ix.event});
assert.equal(viewGame(passed,ix.holder).active,ix.required.find(id=>id!==ix.holder));
reject(passed,ix.holder,{type:'nexusIxianBetrayalUse',event:ix.event});
});
void test('all seats retain independent AI control while native resources and response event are locked',()=>{
const f=createIxianNexusBetrayalFixture({receiverCount:2});
for(const seat of f.game.players) {
const controlled=applyAction(f.game,seat.id,{type:'setAutopilot',difficulty:'Hard'});
assert.deepEqual(controlled.pendingNexusIxianBetrayal,f.game.pendingNexusIxianBetrayal);
assert.deepEqual(ledger(controlled),ledger(f.game));
assert.equal(owner(controlled,seat.id).autopilot,'Hard');
viewGame(controlled,seat.id);
}
for(const a of [{type:'bid',amount:1},{type:'passResponse'},{type:'ready'},{type:'decision',card:owner(f.game,f.provider).hand[0].id}]) reject(f.game,f.holder,a);
});
void test('wrong role, wrong event and selector-bearing actions reject without cost or native mutation',()=>{
const f=createIxianNexusBetrayalFixture();
for(const a of [{type:'nexusIxianBetrayalUse',event:'expired'}, {type:'nexusIxianBetrayalUse',event:f.event,card:'ixians'}, {type:'nexusIxianBetrayalPass',event:f.event,kind:'bidding'}, {type:'nexusIxianBetrayalUse',event:f.event,provider:f.provider}]) reject(f.game,f.holder,a);
reject(f.game,f.provider,{type:'nexusIxianBetrayalPass',event:f.event});
reject(f.game,f.provider,{type:'nexusIxianBetrayalUse',event:f.event});
});
for(const kind of ['bidding','technology'] as const) void test(`${kind} rejects orphan, parent, physical cost and native declaration corruption at read and action boundaries`,()=>{
const f=createIxianNexusBetrayalFixture({kind,advanced:true});
const mutations:((g:Game)=>void)[]=[g=>{g.pendingNexusIxianBetrayal=null;},g=>{delete g.nexusIxianBetrayalPreview;},g=>{g.nexusIxianBetrayalCursor!.sequence++;},g=>{g.pendingNexusIxianBetrayal!.source.parent+='changed';},g=>{g.deck[0].name='forged';},g=>{owner(g,f.provider).spice++;},g=>{g.nexusCards!.cards!.hands[f.holder]=null;},g=>{g.pendingNexusIxianBetrayal!.continuation.owner=f.holder;},g=>{g.turn++;}];
if(kind==='technology') mutations.push(g=>{g.ixTechnologyTurn=0;},g=>{g.pendingIxTechnology!.card=owner(g,f.holder).hand[0].id;},g=>{
const hand=owner(g,f.provider).hand,index=hand.findIndex(c=>c.id===f.selected!.id);
g.discard.push(hand.splice(index,1)[0]);
});
else mutations.push(g=>{g.ixAuction!.count++;});
for(const corrupt of mutations) {
const g=reload(f.game);corrupt(g);const before=structuredClone(g);
assert.throws(()=>viewGame(g,f.holder));assert.throws(()=>normalizeAutomaticGame(g));
reject(g,f.holder,{type:'nexusIxianBetrayalUse',event:f.event});assert.deepEqual(g,before);
}
});
void test('fresh CLI setup continuation preserves original identities, dealt traitor choices and profile without a redeal',()=>{
let lobby=createGame('ORIGINALIX',newPlayer('native','Original Ix','ixians'),true,['ix']);
joinGame(lobby,newPlayer('receiver','Original Atreides','atreides'));
joinGame(lobby,newPlayer('partner','Original Emperor','emperor'));
for(const p of lobby.players) lobby=applyAction(lobby,p.id,{type:'ready'});
lobby.nexusCards={cards:null,phase:null};
const setup=initializeIxianNexusBetrayalGameForAudit(lobby);
assert.equal(setup.status,'setup');assert.equal(setup.nexusCards!.cards!.hands.receiver,null);
const original=structuredClone(setup);
assert.throws(()=>initializeIxianNexusBetrayalGameForAudit(setup));
assert.deepEqual(setup,original);
const fixture=createIxianNexusBetrayalFixture({initial:setup,kind:'technology'});
assert.equal(fixture.game.code,setup.code);assert.equal(fixture.game.host,setup.host);
assert.equal(fixture.game.advanced,setup.advanced);
assert.deepEqual(fixture.game.players.map(p=>[p.id,p.name,p.faction]),setup.players.map(p=>[p.id,p.name,p.faction]));
for(const p of setup.players) assert.ok(fixture.game.players.find(s=>s.id===p.id)!.traitors.every(id=>p.traitorChoices.includes(id)));
assert.deepEqual(setup,original);
});
+297
View File
@@ -0,0 +1,297 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { treacheryDeck } from '../game/cards';
import { NEXUS_FACTIONS, type NexusState } from '../game/nexus-cards';
import {
IxianBetrayalError, closeIxianBetrayalSource, createIxianBetrayalSource,
initialIxianBetrayalCursor, ixianBetrayalEligible, ixianBetrayalResponders,
validateIxianBetrayalFrame, validateIxianBetrayalHistory, validateIxianBetrayalSource,
type IxianBetrayalAuthority, type IxianBetrayalContext, type IxianBetrayalKind,
type IxianBetrayalReceipt,
} from '../game/nexus-ixian-betrayal';
const parent = 'current-original-native-attempt-parent';
function context(face: 'ixians' | 'richese' = 'ixians'): IxianBetrayalContext {
return {
status: 'playing', phase: 3, turn: 2, advanced: true, sequence: 0,
players: [
{ id: 'ix', faction: 'ixians' }, { id: 'holder', faction: 'atreides' },
{ id: 'other', faction: 'fremen' }, { id: 'ally', faction: 'guild' },
],
cards: {
version: 1, deck: NEXUS_FACTIONS.filter(card => card !== face && card !== 'harkonnen'),
discard: [], hands: { ix: null, holder: face, other: 'harkonnen', ally: null },
},
physicalCards: treacheryDeck(['ix']),
};
}
function authority(kind: IxianBetrayalKind = 'bidding'): IxianBetrayalAuthority {
return {
kind, provider: 'ix', nativeWindow: kind === 'bidding' ? 'ixAuction' : 'ixTechnology',
nativeContext: kind === 'bidding' ? 'undrawn-native-pool' : 'declared-unused-lot-exchange',
nativeRequired: ['holder'], nativePassed: ['other', 'holder'],
};
}
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 kind of ['bidding', 'technology'] as const) {
void test(`${kind} authorizes only the current native provider, phase, context and cursor`, () => {
const ctx = context(), native = authority(kind);
const source = createIxianBetrayalSource(ctx, native, parent);
const invalidContexts: IxianBetrayalContext[] = [
{ ...ctx, status: 'setup' }, { ...ctx, phase: 4 }, { ...ctx, turn: ctx.turn + 1 },
{ ...ctx, sequence: 1 }, { ...ctx, advanced: false },
{ ...ctx, players: ctx.players.map(player => player.id === 'ix'
? { ...player, faction: 'emperor' } : player) },
];
for (const changed of invalidContexts) {
assert.throws(() => validateIxianBetrayalSource(changed, source, native, parent));
}
const invalidAuthorities: IxianBetrayalAuthority[] = [
{ ...native, provider: 'holder' }, { ...native, nativeContext: 'another-native-attempt' },
{ ...native, nativePassed: ['holder'] }, { ...native, nativeRequired: [] },
{ ...native, kind: kind === 'bidding' ? 'technology' : 'bidding' },
];
for (const changed of invalidAuthorities) {
assert.throws(() => validateIxianBetrayalSource(ctx, source, changed, parent), IxianBetrayalError);
}
assert.throws(() => validateIxianBetrayalSource(ctx, source, native, 'different-parent'), IxianBetrayalError);
assert.deepEqual(source.required, ['holder', 'other']);
});
void test(`${kind} cannot precede its original native counter completion or change native window`, () => {
const ctx = context(), native = authority(kind);
const invalidAuthorities: IxianBetrayalAuthority[] = [
{ ...native, nativePassed: ['other'] },
{ ...native, nativePassed: ['holder', 'holder'] },
{ ...native, nativeRequired: ['holder', 'holder'] },
{ ...native, nativeRequired: ['absent'], nativePassed: ['absent'] },
{ ...native, nativePassed: ['holder', 'absent'] },
{ ...native, nativeWindow: kind === 'bidding' ? 'ixTechnology' : 'ixAuction' },
{ ...native, provider: 'other' }, { ...native, nativeContext: '' },
];
for (const changed of invalidAuthorities) {
assert.throws(() => createIxianBetrayalSource(ctx, changed, parent), IxianBetrayalError);
}
const noNativeCounter = { ...native, nativeRequired: [], nativePassed: [] };
const source = createIxianBetrayalSource(ctx, noNativeCounter, parent);
validateIxianBetrayalFrame(ctx, source, noNativeCounter, parent, ['holder', 'other'], []);
});
void test(`${kind} requires exact public responder and unique passed membership before any continuation`, () => {
const ctx = context(), native = authority(kind);
const source = createIxianBetrayalSource(ctx, native, parent);
validateIxianBetrayalFrame(ctx, source, native, parent, ['holder', 'other'], ['other']);
for (const [required, passed] of [
[['holder'], []], [['other', 'holder'], []], [['holder', 'other', 'ally'], []],
[['holder', 'other', 'other'], []], [['holder', 'other'], ['holder', 'holder']],
[['holder', 'other'], ['ix']], [['holder', 'other'], ['absent']],
]) {
assert.throws(() => validateIxianBetrayalFrame(ctx, source, native, parent, required, passed), IxianBetrayalError);
}
const missingCard = structuredClone(ctx);
missingCard.cards.hands.other = null;
missingCard.cards.deck.push('harkonnen');
assert.throws(() => validateIxianBetrayalFrame(missingCard, source, native, parent,
['holder', 'other'], []), IxianBetrayalError);
const allied = structuredClone(ctx);
allied.players = allied.players.map(player => player.id === 'holder' ? { ...player, ally: 'ally' }
: player.id === 'ally' ? { ...player, ally: 'holder' } : player);
assert.throws(() => validateIxianBetrayalFrame(allied, source, native, parent,
['holder', 'other'], []), IxianBetrayalError);
});
void test(`${kind} rejects corrupted saved event, descriptor, role, membership and extra authority fields`, () => {
const ctx = context(), native = authority(kind);
const source = createIxianBetrayalSource(ctx, native, parent);
for (const patch of [
{ event: 'invented-event' }, { provider: 'other' }, { sequence: 1 }, { turn: 1 },
{ kind: 'invented' }, { nativeContext: 'substituted-card' }, { signature: 'invented-signature' },
{ required: ['holder'] }, { eligible: 'other' }, { nativePassed: [] }, { extra: 'client-chosen-source' },
]) {
const corrupt = Object.assign(structuredClone(source), patch);
assert.throws(() => validateIxianBetrayalFrame(ctx, corrupt, native, parent,
['holder', 'other'], []), IxianBetrayalError);
}
assert.throws(() => createIxianBetrayalSource(ctx,
Object.assign({}, native, { selectedCard: 'client-chosen-card' }), parent), IxianBetrayalError);
assert.throws(() => createIxianBetrayalSource(ctx, native, ''), IxianBetrayalError);
});
}
void test('Basic authorizes native Bidding but never Technology', () => {
const ctx = { ...context(), advanced: false };
const bidding = createIxianBetrayalSource(ctx, authority('bidding'), parent);
validateIxianBetrayalSource(ctx, bidding, authority('bidding'), parent);
assert.throws(() => createIxianBetrayalSource(ctx, authority('technology'), parent), IxianBetrayalError);
});
void test('public held-card parity does not authorize an irrelevant face, owner or allied holder', () => {
const ix = context(), irrelevant = context('richese');
const realSource = createIxianBetrayalSource(ix, authority(), parent);
const irrelevantSource = createIxianBetrayalSource(irrelevant, authority(), parent);
assert.equal(realSource.event, irrelevantSource.event);
assert.deepEqual(realSource.required, irrelevantSource.required);
assert.throws(() => closeIxianBetrayalSource(irrelevantSource, 'use', 'holder',
initialIxianBetrayalCursor()), IxianBetrayalError);
assert.deepEqual(ixianBetrayalResponders(ix.cards, ix.players), ['holder', 'other']);
assert.deepEqual(ixianBetrayalResponders(irrelevant.cards, irrelevant.players), ['holder', 'other']);
assert.equal(ixianBetrayalEligible(ix.cards, ix.players, 'holder'), true);
assert.equal(ixianBetrayalEligible(irrelevant.cards, irrelevant.players, 'holder'), false);
for (const holder of ['ix', 'other', 'ally', 'absent']) {
assert.equal(ixianBetrayalEligible(ix.cards, ix.players, holder), false);
}
const allied = context();
allied.players = allied.players.map(player => player.id === 'holder' ? { ...player, ally: 'ally' }
: player.id === 'ally' ? { ...player, ally: 'holder' } : player);
assert.deepEqual(ixianBetrayalResponders(allied.cards, allied.players), ['other']);
assert.equal(ixianBetrayalEligible(allied.cards, allied.players, 'holder'), false);
const nativeHeld = context();
nativeHeld.cards.hands.holder = null;
nativeHeld.cards.hands.ix = 'ixians';
assert.deepEqual(ixianBetrayalResponders(nativeHeld.cards, nativeHeld.players), ['other']);
assert.equal(ixianBetrayalEligible(nativeHeld.cards, nativeHeld.players, 'ix'), false);
});
void test('Nexus eligibility cannot accept duplicate, missing, foreign-seat or unknown physical stock', () => {
const variants: NexusState[] = [];
const duplicate = context().cards;
duplicate.deck.push('ixians'); variants.push(duplicate);
const missing = context().cards;
missing.hands.holder = null; variants.push(missing);
const foreign = context().cards;
foreign.hands.foreign = null; variants.push(foreign);
const wrongVersion = context().cards;
Object.assign(wrongVersion, { version: 2 }); variants.push(wrongVersion);
const unknown = context().cards;
Object.assign(unknown.deck, { 0: 'nonphysical-face' }); variants.push(unknown);
for (const cards of variants) {
assert.throws(() => ixianBetrayalEligible(cards, context().players, 'holder'));
assert.throws(() => ixianBetrayalResponders(cards, context().players));
}
const ctx = context();
ctx.players = ctx.players.map(player => player.id === 'other' ? { ...player, ally: 'holder' } : player);
assert.throws(() => ixianBetrayalEligible(ctx.cards, ctx.players, 'holder'));
});
void test('both native kinds require canonical complete Ix47 stock, not matching fabricated descriptors', () => {
for (const kind of ['bidding', 'technology'] as const) {
const native = authority(kind);
for (const patch of [
{ name: 'Substituted face' }, { kind: 'special' }, { effect: 'fabricated' },
{ id: 'foreign-physical-card' }, { extra: 'private-descriptor-field' },
]) {
const ctx = context();
const corrupt = Object.assign({}, ctx.physicalCards[0], patch);
ctx.physicalCards = [corrupt, ...ctx.physicalCards.slice(1)];
assert.throws(() => createIxianBetrayalSource(ctx, native, parent), IxianBetrayalError);
}
for (const cards of [treacheryDeck(), treacheryDeck(['ix']).slice(1),
[...treacheryDeck(['ix']), treacheryDeck(['ix'])[0]],
treacheryDeck(['ix']).map((card, index) => index === 0 ? treacheryDeck(['ix'])[1] : card),
]) {
assert.throws(() => createIxianBetrayalSource({ ...context(), physicalCards: cards }, native, parent), IxianBetrayalError);
}
const excluded = context();
excluded.players = excluded.players.map(player => player.id === 'other' ? { ...player, faction: 'richese' } : player);
assert.throws(() => createIxianBetrayalSource(excluded, native, parent), IxianBetrayalError);
}
});
void test('creation, validation and closure accept frozen inputs without aliasing source memberships', () => {
const ctx = context(), native = authority();
const snapshot = structuredClone({ ctx, native });
freeze(ctx); freeze(native);
const source = createIxianBetrayalSource(ctx, native, parent);
freeze(source);
const cursor = initialIxianBetrayalCursor(); freeze(cursor);
const closed = closeIxianBetrayalSource(source, 'use', 'holder', cursor);
validateIxianBetrayalFrame(ctx, source, native, parent, source.required, ['other']);
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards);
assert.deepEqual({ ctx, native }, snapshot);
assert.deepEqual(cursor, initialIxianBetrayalCursor());
assert.notEqual(closed.receipt.source, source);
for (const key of ['required', 'nativeRequired', 'nativePassed'] as const) {
assert.notEqual(closed.receipt.source[key], source[key]);
assert.deepEqual(closed.receipt.source[key], source[key]);
}
});
void test('closure enforces use/pass outcomes, required holder and independent exactly-once cursor', () => {
const source = createIxianBetrayalSource(context(), authority(), parent);
const cursor = initialIxianBetrayalCursor();
for (const holder of ['ix', 'other', 'ally', 'absent', null]) {
assert.throws(() => closeIxianBetrayalSource(source, 'use', holder, cursor), IxianBetrayalError);
}
assert.throws(() => closeIxianBetrayalSource(source, 'pass', 'holder', cursor), IxianBetrayalError);
const used = closeIxianBetrayalSource(source, 'use', 'holder', cursor);
assert.equal(used.cursor.sequence, 1);
assert.throws(() => closeIxianBetrayalSource(source, 'use', 'holder', used.cursor), IxianBetrayalError);
assert.throws(() => validateIxianBetrayalSource({ ...context(), sequence: 1 }, source, authority(), parent), IxianBetrayalError);
const passed = closeIxianBetrayalSource(source, 'pass', null, cursor);
validateIxianBetrayalHistory([passed.receipt], passed.cursor);
assert.throws(() => validateIxianBetrayalHistory([used.receipt], passed.cursor), IxianBetrayalError);
});
void test('closed history remains valid after physical Nexus recycling and a later holder change', () => {
const ctx = context();
const first = createIxianBetrayalSource(ctx, authority(), parent);
const closed = closeIxianBetrayalSource(first, 'use', 'holder', initialIxianBetrayalCursor());
ctx.cards.hands.holder = null;
ctx.cards.discard.push('ixians');
validateIxianBetrayalHistory([closed.receipt], closed.cursor, [...ctx.physicalCards].reverse());
assert.equal(ixianBetrayalEligible(ctx.cards, ctx.players, 'holder'), false);
// A later settled Nexus can recycle the same physical card into another hand.
ctx.cards.discard.pop();
ctx.cards.deck.push('harkonnen');
ctx.cards.hands.other = 'ixians';
ctx.turn++; ctx.sequence = closed.cursor.sequence;
const second = createIxianBetrayalSource(ctx, authority('technology'), 'later-native-parent');
const later = closeIxianBetrayalSource(second, 'use', 'other', closed.cursor);
assert.equal(ixianBetrayalEligible(ctx.cards, ctx.players, 'other'), true);
validateIxianBetrayalHistory([closed.receipt, later.receipt], later.cursor, ctx.physicalCards);
assert.throws(() => validateIxianBetrayalSource(ctx, first, authority(), parent), IxianBetrayalError);
});
void test('history rejects deletion, duplicate/reordered closure, forged outcomes and disconnected cursor', () => {
const ctx = context();
const first = closeIxianBetrayalSource(createIxianBetrayalSource(ctx, authority(), parent),
'use', 'holder', initialIxianBetrayalCursor());
ctx.sequence = first.cursor.sequence;
const second = closeIxianBetrayalSource(createIxianBetrayalSource(ctx, authority('technology'), parent),
'pass', null, first.cursor);
const history = [first.receipt, second.receipt];
for (const corrupt of [[], [second.receipt], [first.receipt, first.receipt],
[second.receipt, first.receipt], [first.receipt, second.receipt, second.receipt]]) {
assert.throws(() => validateIxianBetrayalHistory(corrupt, second.cursor), IxianBetrayalError);
}
for (const patch of [{ holder: 'other' }, { outcome: 'pass' }, { previous: 'unrelated-head' },
{ signature: 'forged' }, { extra: 'hidden-current-ownership' }]) {
const changed = Object.assign(structuredClone(first.receipt), patch);
assert.throws(() => validateIxianBetrayalHistory([changed, second.receipt], second.cursor), IxianBetrayalError);
}
assert.throws(() => validateIxianBetrayalHistory(history, first.cursor), IxianBetrayalError);
assert.throws(() => validateIxianBetrayalHistory(history, { sequence: 2, signature: 'disconnected' }), IxianBetrayalError);
});
void test('persisted multi-attempt history grows linearly with bounded predecessor links', () => {
const ctx = context();
const history: IxianBetrayalReceipt[] = [];
let cursor = initialIxianBetrayalCursor();
for (let index = 0; index < 80; index++) {
ctx.turn = 1 + Math.floor(index / 8); ctx.sequence = cursor.sequence;
const source = createIxianBetrayalSource(ctx, authority(index % 2 ? 'technology' : 'bidding'), parent);
const closed = closeIxianBetrayalSource(source, index % 2 ? 'use' : 'pass', index % 2 ? 'holder' : null, cursor);
history.push(closed.receipt); cursor = closed.cursor;
}
validateIxianBetrayalHistory(history, cursor, ctx.physicalCards);
const firstSize = JSON.stringify(history[0]).length;
assert.ok(JSON.stringify(history.at(-1)).length < firstSize * 2,
'later saved attempts must not recursively embed previous full receipts');
assert.ok(JSON.stringify(history).length < firstSize * history.length * 2,
'a full game horizon must remain linearly serializable for saved-room recovery');
});
+165
View File
@@ -19,6 +19,7 @@ import {
startPrototypeRoom,
} from '../tools/prototype-room';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { createLeaderSkills } from '../game/leader-skills';
function fixture(t: test.TestContext) {
const db = new DatabaseSync(':memory:');
@@ -487,3 +488,167 @@ void test('Ixian replacement CLI takes a private exact-version backup before sta
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
});
for (const advanced of [false, true]) {
for (const seats of [2, 3, 4, 6]) {
void test(`Ixian Betrayal fresh ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry keeps original identity and undealt Nexus`, t => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), advanced, ['ix']);
const factions = ['atreides', 'emperor', 'guild', 'tleilaxu', 'fremen'] as const;
for (let index = 0; index < seats - 1; index++)
joinGame(game, newPlayer(`p${index}`, `Original ${index}`, factions[index]));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const credentials = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 6, 'ixian-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
const started = startPrototypeRoom(db, game.code, 7, 'ixian-betrayal');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(started.version, 8);
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.code, game.code);
assert.equal(saved.host, game.host);
assert.equal(saved.advanced, game.advanced);
assert.deepEqual(saved.expansions, game.expansions);
assert.deepEqual(saved.playerPositions, game.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), game.players.map(p => [p.id, p.name, p.faction]));
assert.equal(saved.nexusIxianBetrayalPreview, true);
const physical = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand), ...(saved.ixSetupCards ?? [])];
assert.equal(physical.length, 47);
assert.equal(new Set(physical.map(card => card.id)).size, 47);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual(saved.nexusCards!.cards!.discard, []);
for (const p of saved.players) {
assert.equal(saved.nexusCards!.cards!.hands[p.id], null);
const view = viewGame(saved, p.id);
assert.equal(view.nexusIxianBetrayalPreview, true);
assert.equal(view.nexusIxianBetrayalReaction, null);
assert.equal(view.nexusCards!.card, null);
assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
}
const after = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ixian-betrayal'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ix'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
}
void test('Ixian Betrayal rejects incompatible original lobbies and public start without durable changes', t => {
const { db } = fixture(t);
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
joinGame(native, newPlayer('a', 'Atreides', 'atreides'));
for (const p of native.players) p.ready = true;
const alterations: ((g: Game) => void)[] = [
g => { g.players[0].ready = false; },
g => { g.code = 'NOTMATCH'; },
g => { g.players[0].faction = 'emperor'; },
g => { g.players[1].faction = 'ixians'; },
g => { g.players[1].faction = 'choam'; },
g => { g.players[1].faction = 'richese'; },
g => { g.players[1].faction = 'ecaz'; },
g => { g.players[1].faction = 'moritani'; },
g => { g.expansions = []; },
g => { g.expansions = ['ix', 'choam']; },
g => { g.expansions = ['ix', 'ecaz']; },
g => { g.discoveryEnabled = true; },
g => { g.ecazTreachery = true; },
g => { g.semutaPreview = true; },
g => { g.nexusIxianReplacementPreview = true; },
g => { g.nexusIxianBetrayalPreview = true; },
g => { g.kullPreview = true; },
g => { g.nexusKullPreview = true; },
g => { g.guildBetrayalPreview = true; },
g => { g.richeseBetrayalPreview = true; },
g => { g.moritaniAssassinatePreview = true; },
g => { g.leaderSkills = createLeaderSkills(() => 0.5); },
g => { g.players.pop(); },
g => { g.players.push(...Array.from({ length: 5 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
];
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
const game = applyAction(native, native.host, { type, enabled: true });
for (const p of game.players) p.ready = true;
return game;
});
const candidates = alterations.map(change => {
const game = structuredClone(native);
change(game);
return game;
}).concat(modules);
for (const game of candidates) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats').all();
const changes = db.prepare('SELECT total_changes() AS changes').get()!.changes;
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'ixian-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.equal(db.prepare('SELECT total_changes() AS changes').get()!.changes, changes);
}
const original = structuredClone(native);
for (const action of [{ type: 'start' }, { type: 'start', advancedPreview: true }])
assert.throws(() => applyAction(native, native.host, action));
assert.deepEqual(native, original);
});
void test('Ixian Betrayal CLI preserves a private consistent exact-version backup and never overwrites proof or resets a started room', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-ixian-betrayal-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), true, ['ix']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) p.ready = true;
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(game.code, JSON.stringify(game), 7, 100);
const other = createGame('KEEPGAME', newPlayer('a', 'Atreides', 'atreides'), true);
joinGame(other, newPlayer('e', 'Emperor', 'emperor'));
for (const p of other.players) p.ready = true;
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
startPrototypeRoom(db, other.code, 9, 'ixian-replacement');
for (const p of game.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(game.code, p.id, 'isolated-test-' + p.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
db.close();
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'ixian-betrayal', '--db', database, '--room', game.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const started = invoke(7, output);
assert.equal(started.status, 0, started.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
backup.close();
const current = new DatabaseSync(database, { readOnly: true });
t.after(() => current.close());
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
const savedRow = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(game.code)!;
assert.equal(savedRow.version, 8);
const saved = JSON.parse(savedRow.state as string) as Game;
assert.equal(saved.nexusIxianBetrayalPreview, true);
assert.equal(saved.status, 'setup');
assert.equal(saved.advanced, game.advanced);
assert.deepEqual(saved.expansions, game.expansions);
assert.deepEqual(saved.playerPositions, game.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), game.players.map(p => [p.id, p.name, p.faction]));
assert.notEqual(invoke(8, output).status, 0);
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
});