Enable Advanced selected-deck Harkonnen Nexus reaction
Verify and publish checkpoint container / container (push) Successful in 10m1s
Verify and publish checkpoint container / container (push) Successful in 10m1s
This commit is contained in:
1 parent
3bfcd10140
commit
eaaab566e2
9 files changed
+288
-27
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAuditorLeader, leaders, treacheryDeck } from '../game/cards';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { createTraitorDeclaration } from '../game/traitor-declarations';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { closeHarkonnenBetrayalSource, createHarkonnenBetrayalReplacement,
|
||||
createHarkonnenBetrayalSource, drawHarkonnenBetrayalReplacement,
|
||||
initialHarkonnenBetrayalCursor, validateHarkonnenBetrayalHistory,
|
||||
validateHarkonnenBetrayalSource, type HarkonnenBetrayalContext } from '../game/nexus-harkonnen-betrayal';
|
||||
|
||||
function context(advanced = true): HarkonnenBetrayalContext {
|
||||
const players: HarkonnenBetrayalContext['players'] = [
|
||||
{id:'h',faction:'harkonnen',ally:null}, {id:'c',faction:'choam',ally:null},
|
||||
{id:'e',faction:'ecaz',ally:null}, {id:'t',faction:'tleilaxu',ally:null},
|
||||
{id:'m',faction:'moritani',ally:null}, {id:'a',faction:'atreides',ally:null},
|
||||
];
|
||||
const expansions = ['ix','choam','ecaz'];
|
||||
const loyalty = advanced ? {player:'e',card:'ecaz-0'} : undefined;
|
||||
const universe = traitorDeck(players.map(player => ({leaders:[...leaders(player.faction),
|
||||
...(advanced && player.faction === 'choam' ? [createAuditorLeader()] : [])]})), true)
|
||||
.filter(card => card !== loyalty?.card);
|
||||
const target = advanced ? 'choam-auditor' : 'choam-0';
|
||||
const dancers = ['atreides-0','tleilaxu-0','ecaz-1'];
|
||||
const removed = ['moritani-0'];
|
||||
const traitorPlayers = players.map(player => ({id:player.id,faction:player.faction,
|
||||
traitors:player.id === 'h' ? [target] : [],
|
||||
...(player.id === 't' ? {faceDancers:dancers.map(leader => ({leader,revealed:false}))} : {})}));
|
||||
const occupied = [target,...dancers,...removed];
|
||||
const physicalCards = treacheryDeck(expansions);
|
||||
return {status:'playing',phase:6,turn:2,advanced,sequence:0,expansions,players,physicalCards,
|
||||
...(loyalty ? {ecazLoyalty:loyalty} : {}), universe,
|
||||
cards:{version:1,deck:NEXUS_FACTIONS.filter(card => card !== 'harkonnen'),discard:[],
|
||||
hands:{h:null,c:null,e:null,t:null,m:null,a:'harkonnen'}},
|
||||
traitors:{reserve:universe.filter(card => !occupied.includes(card)),players:traitorPlayers,removed},
|
||||
battle:{event:'advanced-auditor-battle',attacker:'h',defender:'c',
|
||||
plans:{h:{leader:'harkonnen-0'},c:{leader:target}},
|
||||
heroLeaderIds:physicalCards.filter(card => card.kind === 'hero').map(card => card.id)}};
|
||||
}
|
||||
function authority(ctx: HarkonnenBetrayalContext) {
|
||||
const declaration = createTraitorDeclaration({...ctx.battle,players:ctx.players.map(player => ({...player,
|
||||
traitors:ctx.traitors.players.find(seat => seat.id === player.id)!.traitors}))},'h');
|
||||
return {provider:'h',declaration,nativeWindow:'direct' as const,nativeContext:'native',nativeRequired:[],nativePassed:[]};
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) void test(`${advanced?'Advanced Auditor and Loyalty':'Basic native'} selected census preserves the original traitor`, () => {
|
||||
const ctx=context(advanced), native=authority(ctx), source=createHarkonnenBetrayalSource(ctx,native,'parent');
|
||||
assert.equal(source.declaration.identity,advanced?'choam-auditor':'choam-0');
|
||||
assert.equal(source.universe.includes('ecaz-0'),!advanced);
|
||||
assert.equal(source.universe.includes('choam-auditor'),advanced);
|
||||
for (const outcome of ['pass','use'] as const) {
|
||||
const closed=closeHarkonnenBetrayalSource(source,outcome,outcome==='use'?'a':null,initialHarkonnenBetrayalCursor());
|
||||
validateHarkonnenBetrayalHistory([closed.receipt],closed.cursor,ctx.universe);
|
||||
if (outcome === 'use') {
|
||||
const replacement=createHarkonnenBetrayalReplacement(closed.receipt);
|
||||
assert.equal(drawHarkonnenBetrayalReplacement(replacement,[closed.receipt],closed.cursor,ctx.traitors.reserve[0],ctx.universe).status,'drawn');
|
||||
}
|
||||
}
|
||||
const stored=JSON.parse(source.inventory);
|
||||
assert.deepEqual(stored.at(-1),advanced?{expansions:ctx.expansions,ecazLoyalty:ctx.ecazLoyalty}:{expansions:ctx.expansions});
|
||||
});
|
||||
|
||||
void test('Advanced census rejects altered Loyalty, Auditor, Face Dancers and retired cards', () => {
|
||||
const ctx=context(), native=authority(ctx), source=createHarkonnenBetrayalSource(ctx,native,'parent');
|
||||
for (const mutate of [
|
||||
(g:HarkonnenBetrayalContext) => { g.ecazLoyalty!.card='ecaz-2'; },
|
||||
(g:HarkonnenBetrayalContext) => { g.ecazLoyalty!.player='h'; },
|
||||
(g:HarkonnenBetrayalContext) => { g.ecazLoyalty!.card=null; },
|
||||
(g:HarkonnenBetrayalContext) => { g.universe=g.universe.filter(card => card !== 'choam-auditor'); },
|
||||
(g:HarkonnenBetrayalContext) => { g.traitors.reserve.push(g.traitors.players.find(p => p.id==='t')!.faceDancers![0].leader); },
|
||||
(g:HarkonnenBetrayalContext) => { g.traitors.removed!.push(g.traitors.reserve[0]); },
|
||||
]) {
|
||||
const bad=structuredClone(ctx); mutate(bad);
|
||||
assert.throws(() => createHarkonnenBetrayalSource(bad,native,'parent'));
|
||||
assert.throws(() => validateHarkonnenBetrayalSource(bad,source,native,'parent'));
|
||||
}
|
||||
const altered=structuredClone(source), inventory=JSON.parse(altered.inventory);
|
||||
inventory.at(-1).ecazLoyalty.card='ecaz-2'; altered.inventory=JSON.stringify(inventory);
|
||||
assert.throws(() => validateHarkonnenBetrayalSource(ctx,altered,native,'parent'));
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game} from '../game/engine';
|
||||
import {TERRITORIES} from '../game/board';
|
||||
import {traitorDeck} from '../game/traitors';
|
||||
import {withoutEcazLoyalty} from '../game/ecaz-loyalty';
|
||||
import {validateNexusTraitorSnapshot} from '../game/nexus-traitor-exchange';
|
||||
import {botActions} from '../game/bots';
|
||||
import {DIFFICULTIES} from '../game/bot-profiles';
|
||||
import {nextHarkonnenBetrayalNativeStep, settleHarkonnenBetrayalNativeCounters,
|
||||
} from './fixture-nexus-harkonnen-betrayal';
|
||||
import {nextRicheseStrongholdsNativeStep} from './fixture-richese-strongholds';
|
||||
|
||||
const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action);
|
||||
const player = (game:Game, id:string) => {const seat=game.players.find(p=>p.id===id);assert.ok(seat);return seat;};
|
||||
const step = (game:Game) => {
|
||||
if(game.pendingGuildBetrayal) {const frame=game.pendingGuildBetrayal;const actor=frame.required.find(id=>!frame.passed.includes(id));assert.ok(actor);return act(game,actor,{type:'guildBetrayalPass',event:frame.invoice.event});}
|
||||
const next=(!game.response && !game.phaseOpening && (game.decision?.kind.startsWith('richese') || game.richeseAuction && !game.richeseAuction.outcome)) ? nextRicheseStrongholdsNativeStep(game)! : nextHarkonnenBetrayalNativeStep(game); try {return act(game,next.actor,next.action);} catch (error) {throw Error(`Step ${game.turn}/${game.phase}/${game.decision?.kind} ${JSON.stringify(next)}: ${String(error)}`);}};
|
||||
function advance(game:Game, until:(game:Game)=>boolean, limit=1800):Game {
|
||||
for(let i=0;i<limit;i++) {if(until(game)) return game; assert.notEqual(game.status,'finished',`Unexpected end at turn ${game.turn} phase ${game.phase}; log ${JSON.stringify(game.log.slice(-5))}`); game=step(game);}
|
||||
throw Error(`Native expanded Nexus journey stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
function inventory(game:Game):string[] {
|
||||
return [...game.deck,...game.discard,...(game.richeseCache??[]),...(game.richeseRemoved??[]),
|
||||
...(game.auction?.cards.slice(game.auction.index)??[]),...game.players.flatMap(p=>p.hand)].map(card=>card.id).sort();
|
||||
}
|
||||
function traitors(game:Game):void {
|
||||
validateNexusTraitorSnapshot({reserve:game.traitorReserve!,players:game.players.map(({id,faction,traitors,faceDancers})=>({id,faction,traitors,...(faceDancers?{faceDancers}: {})}))},withoutEcazLoyalty(traitorDeck(game.players,game.expansions.includes('ix')),game.ecazLoyalty));
|
||||
}
|
||||
/** Native public selected-deck start, original setup and second-turn Nexus
|
||||
* draw. Conserved unopened deck order, one held traitor and two physical
|
||||
* reserve positions are staged; battle selection, plans and call are real. */
|
||||
function fixture(target:'ixians'|'tleilaxu'|'choam'|'richese'|'auditor') {
|
||||
const faction = target === 'auditor' ? 'choam' : target;
|
||||
let game=createGame('ADVHARKEXPANDED',newPlayer('i',faction,faction),true,['ix','choam','ecaz']);
|
||||
const partner = target === 'richese' ? 'choam' : 'richese';
|
||||
for(const [id,faction] of [['h','harkonnen'],['r',partner],['g','guild']] as const)
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
game=act(game,'i',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
for(const seat of game.players) game=act(game,seat.id,{type:'ready'});
|
||||
game=act(game,'i',{type:'start',advancedPreview:true});
|
||||
assert.equal(game.nexusHarkonnenBetrayalPreview,true);
|
||||
game=advance(game,state=>state.status==='playing');
|
||||
const physical=inventory(game);traitors(game);
|
||||
assert.ok(game.richeseCache?.length,'Native Richese retains its separate cache');
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card=>card===null));
|
||||
const identity=player(game,'i').leaders.find(leader=>!leader.dead && leader.id!==game.ecazLoyalty?.card &&
|
||||
(target === 'auditor' ? leader.id === 'choam-auditor' : leader.id !== 'choam-auditor'))!.id;
|
||||
const h=player(game,'h');
|
||||
if(!h.traitors.includes(identity)) {
|
||||
const replacement=h.traitors[0];
|
||||
const owner=game.players.find(seat=>seat.id!=='h'&&seat.traitors.includes(identity));
|
||||
if(owner) owner.traitors[owner.traitors.indexOf(identity)]=replacement;
|
||||
else {
|
||||
const dancer=game.players.flatMap(seat=>seat.faceDancers??[]).find(card=>card.leader===identity);
|
||||
if(dancer) dancer.leader=replacement;
|
||||
else {const index=game.traitorReserve!.indexOf(identity);assert.ok(index>=0);game.traitorReserve![index]=replacement;}
|
||||
}
|
||||
h.traitors[0]=identity;
|
||||
}
|
||||
traitors(game);
|
||||
const nexus=game.nexusCards!.cards!;
|
||||
const nx=nexus.deck.indexOf('harkonnen');assert.ok(nx>=0);
|
||||
nexus.deck.unshift(nexus.deck.splice(nx,1)[0]);
|
||||
for(let pos=0;pos<2;pos++) {
|
||||
const index=game.spiceDeck.findIndex((card,i)=>i>=pos&&'territory'in card);assert.ok(index>=pos);
|
||||
game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]);
|
||||
}
|
||||
game=advance(game,state=>state.turn===2&&state.phase===1&&!state.response&&!state.decision);
|
||||
const worm=game.spiceDeck.findIndex(card=>'worm'in card&&!card.greatMaker&&!card.suppressed);assert.ok(worm>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
|
||||
// Keep Sandtrout from breaking the alliance during the second Spice Blow
|
||||
// before the genuine Nexus closes and awards its held card.
|
||||
for(let pos=1;pos<=2;pos++) {
|
||||
const index=game.spiceDeck.findIndex((card,i)=>i>=pos&&'territory'in card);assert.ok(index>=pos);
|
||||
game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]);
|
||||
}
|
||||
game=advance(game,state=>state.nexus&&!state.spiceWindow&&!state.spiceResolution&&!state.response&&!state.decision);
|
||||
game=act(game,'h',{type:'alliance',target:'r'});game=act(game,'r',{type:'alliance',target:'h'});
|
||||
game=advance(game,state=>state.turn>2||state.nexusCards?.phase?.stage==='drawing');
|
||||
assert.equal(game.turn,2,`Nexus drawing was skipped: ${JSON.stringify(game.log.slice(-25))}`);
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('i'));
|
||||
game=act(game,'i',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
assert.equal(game.nexusCards!.cards!.hands.i,'harkonnen');
|
||||
game=advance(game,state=>state.phase===5&&!state.response&&!state.decision);
|
||||
const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&
|
||||
game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))));assert.ok(territory);
|
||||
for(const id of ['h','i']) {const seat=player(game,id);assert.ok(seat.reserves>0);seat.reserves--;seat.forces[`${territory.id}:${territory.sectors[0]}`]=1;}
|
||||
game=advance(game,state=>state.phase===6&&!!state.active&&!state.phaseOpening&&!state.response&&!state.decision);
|
||||
const chooser=game.active!;assert.ok(['h','i'].includes(chooser), `chooser ${chooser}; battle ${JSON.stringify(game.battle)}; decision ${JSON.stringify(game.decision)}; pending ${JSON.stringify(game.pendingGuildBetrayal)}; armies ${JSON.stringify(game.players.map(p=>[p.id,p.forces]))}`);
|
||||
game=act(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser==='h'?'i':'h'});
|
||||
game=advance(game,state=>!!state.battle&&!state.response&&!state.decision&&!state.battle.preparation&&
|
||||
(!state.battle.preLeader||state.battle.preLeader.closed));
|
||||
for(const id of [game.battle!.attacker,game.battle!.defender]) {
|
||||
const leader=id==='i'?identity:player(game,id).leaders.find(l=>!l.dead&&!l.usedAt)!.id;
|
||||
game=act(game,id,{type:'battlePlan',dial:0,leader});
|
||||
}
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
game=advance(game,state=>!state.response&&!state.decision);
|
||||
game=act(game,'h',{type:'traitorCall',call:true});
|
||||
game=settleHarkonnenBetrayalNativeCounters(game);
|
||||
const reaction=viewGame(game,'i').nexusHarkonnenBetrayalReaction;
|
||||
assert.ok(reaction,'The actual expansion-roster traitor call opens native Betrayal');
|
||||
assert.equal(reaction.canUse,true);assert.equal(reaction.identity,identity);
|
||||
assert.deepEqual(inventory(game),physical);traitors(game);
|
||||
return {game,event:reaction.event,identity,physical};
|
||||
}
|
||||
|
||||
for(const target of ['ixians','tleilaxu','choam','richese','auditor'] as const) for(const use of [false,true]) void test(`Advanced selected-deck ${target} leader: Harkonnen Nexus Betrayal ${use?'Use':'Pass'}`,()=>{
|
||||
const f=fixture(target);let game=f.game;
|
||||
if(use) for(const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(game,'i');view.players.find(p=>p.id==='i')!.bot=difficulty;
|
||||
const action=botActions(view).find(a=>a.type==='nexusHarkonnenBetrayalUse');
|
||||
assert.ok(action,`${difficulty} offers its legal Use`);
|
||||
assert.doesNotThrow(()=>act(structuredClone(game),'i',action));
|
||||
}
|
||||
if(use) game=act(game,'i',{type:'nexusHarkonnenBetrayalUse',event:f.event});
|
||||
else {
|
||||
for(const id of game.pendingNexusHarkonnenBetrayal!.required)
|
||||
game=act(game,id,{type:'nexusHarkonnenBetrayalPass',event:f.event});
|
||||
}
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal,null);
|
||||
assert.equal(game.nexusCards!.cards!.hands.i,use?null:'harkonnen');
|
||||
assert.equal(game.battle!.traitorCalls.h,!use);
|
||||
assert.equal(player(game,'h').traitors.includes(f.identity),!use);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null);
|
||||
assert.deepEqual(inventory(game),f.physical);traitors(game);
|
||||
game=advance(game,state=>state.phase===8);
|
||||
if (game.phaseOpening) assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null);
|
||||
game=advance(game,state=>state.phase===8&&!state.pendingNexusHarkonnenReplacement&&!state.phaseOpening);
|
||||
assert.equal(game.lastBattleContext!.result,use?'normal':'traitor');
|
||||
assert.equal(game.nexusHarkonnenReplacementHistory!.length,use?1:0);
|
||||
assert.equal(player(game,'h').traitors.length,4);
|
||||
assert.deepEqual(inventory(game),f.physical);traitors(game);
|
||||
});
|
||||
@@ -60,6 +60,6 @@ for (const hero of [false, true]) void test(`Basic selected Ix and CHOAM census
|
||||
{...ctx, physicalCards:ctx.physicalCards.slice(1)},
|
||||
{...ctx, universe:ctx.universe.filter(id => id !== CHEAP_HERO_TRAITOR)},
|
||||
{...ctx, battle:{...ctx.battle, heroLeaderIds:[]}},
|
||||
{...ctx, advanced:true},
|
||||
{...ctx, advanced:true, ecazLoyalty:{player:'i', card:'ixians-0'}},
|
||||
]) assert.throws(() => createHarkonnenBetrayalSource(bad, authority, 'original-battle'));
|
||||
});
|
||||
Reference in new issue
Block a user