226 lines
16 KiB
TypeScript
226 lines
16 KiB
TypeScript
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);
|
|
});
|