import assert from 'node:assert/strict'; import {applyAction,createGame,handLimit,initializeLeaderSkillsGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine'; import {botActions} from '../game/bots'; import {TERRITORIES} from '../game/board'; import {LEADER_SKILL_CARDS} from '../game/leader-skill-cards'; import type {FactionId} from '../game/catalog'; import {quoteNormalAuctionNext} from '../game/normal-auction'; import type {BankerIncomeSourceKind} from '../game/spice-banker-income'; import {forceRevivalQuote} from '../game/revival'; import {quoteBattleResolution,type ResolutionCombatant} from '../game/battle-resolution-quote'; import {quoteSpiceCollection} from '../game/board-resolution-quote'; import {leaderSkillStrongholdCount} from '../game/leader-skill-battle-board'; import type {Card} from '../game/cards'; export type SpiceBankerIncomeFixtureOptions = {initial?:Game;advanced?:boolean;kind?:BankerIncomeSourceKind|'cross-shipment'|'return-shipment';ownerIsPayer?:boolean;payerFaction?:FactionId;bankerFaction?:FactionId;revivalBeforeBanker?:FactionId;stageKarama?:boolean;stageIncomeCounter?:boolean;amount?:number;allySplit?:number;emperor?:boolean;guild?:boolean;seatIds?:string[];trainerSelected?:boolean;trainerDies?:boolean;trainerHidden?:boolean}; export type SpiceBankerIncomeNativeStep = {actor:string;action:Action}; export type SpiceBankerIncomeFixture = {game:Game;beforePayment:Game;actor:string;payer:string;paymentAction:Action;owner:string;leader:string;event:string;kind:NonNullable;amount:number;bankLegs:{payer:string;amount:number}[]}; export function nextSpiceBankerIncomeNativeStep(game:Game):SpiceBankerIncomeNativeStep|null { if(game.status==='finished') return null; if(game.pendingTreacheryDiscard) return {actor:game.host,action:{type:'advanceBots'}}; if(game.phaseOpening) return {actor:game.players.find(p=>!game.phaseOpening!.passed.includes(p.id))!.id,action:{type:'ready'}}; if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}}; if(game.decision?.kind==='bureaucratPayment') return {actor:game.decision.player,action:{type:'decision',event:game.decision.event,redirect:false}}; if(game.decision?.kind==='auctionPayment') return {actor:game.decision.player,action:{type:'decision',karama:false}}; if(game.decision?.kind==='guildShipment') return {actor:game.decision.player,action:{type:'decision',allow:true}}; if(game.decision?.kind==='captureOffer') return {actor:game.decision.player,action:{type:'decision',accept:false}}; if(game.decision?.kind==='leaderSkillVisibility') return {actor:game.decision.player,action:{type:'leaderSkillVisibility',event:game.decision.event,hide:false}}; if(game.decision?.kind==='mentatQuestion') return {actor:game.decision.player,action:{type:'decision',event:game.decision.event,decline:true}}; if(game.battle?.preparation) return {actor:game.battle.preparation.owner,action:{type:'declineBattlePower'}}; if(game.decision?.kind==='fullPlanOffer') return {actor:game.decision.player,action:{type:'decision',decline:true}}; if(game.status==='playing'&&!game.decision) { if(game.phase===0&&game.stormPending===null) return {actor:game.stormDialers.find(id=>game.stormDials[id]===undefined)!,action:{type:'stormDial',amount:game.turn===1?0:1}}; if(game.phase===3&&game.auction) return {actor:game.auction.active,action:{type:'passBid'}}; if(game.phase===5) return {actor:game.active!,action:{type:'endMovement'}}; if(game.battle?.revealed) { const voter=viewGame(game,game.host).battle!.traitorVoters.find(id=>game.battle!.traitorCalls[id]===undefined); if(voter) return {actor:voter,action:{type:'traitorCall',call:false}}; } if(game.phase===4&&game.revivalOrder) { const actor=game.revivalOrder.remaining[0]; if(actor) return {actor,action:{type:'ready'}}; } if([1,2,4,7,8].includes(game.phase)||(game.phase===0&&game.stormPending!==null)) { 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 action=botActions(view)[0]; if(action) return {actor:p.id,action}; } throw new Error(`No native Banker fixture step at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`); } function step(game:Game):Game { const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next); return applyAction(game,next.actor,next.action); } export function advanceSpiceBankerIncomeToMentat(state:Game):Game { let game=state;const turn=game.turn; for(let i=0;(game.phase!==8||game.phaseOpening)&&game.turn===turn&&game.status==='playing'&&i<1000;i++) game=step(game); assert.equal(game.turn,turn);assert.equal(game.phase,8); return game; } /** Scope deterministic entropy to the first real initializer, including CLI admission. */ export function withSpiceBankerIncomeSetupRandomness(initialize:()=>T):T { const descriptor=Object.getOwnPropertyDescriptor(crypto,'getRandomValues'); const index=LEADER_SKILL_CARDS.findIndex(c=>c.id==='spice-banker');let shuffleIndex=LEADER_SKILL_CARDS.length-1; crypto.getRandomValues=(array:V):V=>{ assert.ok(array);const values=new Uint32Array(array.buffer,array.byteOffset,array.byteLength/4); for(let i=0;iinitializeLeaderSkillsGameForAudit(game)); } else assert.ok(game.status==='setup'&&game.spiceBankerIncomePreview&&game.turn===1&&game.phase===0,'Only the original fresh admitted setup can continue.'); for(let i=0;game.status==='setup'&&i<200;i++) { if(game.setupStage==='leaderSkills') { const owner=Object.keys(game.leaderSkills!.offers)[0]; const offer=game.leaderSkills!.offers[owner]; const view=viewGame(game,owner).leaderSkills!; const skill=offer.cards.includes('spice-banker')?'spice-banker':offer.cards.find(c=>!view.unavailableSkills?.[c]); assert.ok(skill);assert.ok(view.eligibleLeaders.length); game=applyAction(game,owner,{type:'leaderSkill',event:offer.event,skill,leader:view.eligibleLeaders[0].id}); } else game=step(game); } assert.equal(game.status,'playing'); assert.ok(game.leaderSkills!.assignments.some(a=>a.skill==='spice-banker'),'The original native setup has no real Spice Banker offer; fixture prerequisite missing.'); return game; } function phase(game:Game,target:number):Game { for(let i=0;(game.phase!==target||game.phaseOpening||game.response||game.decision)&&i<1000;i++) game=step(game); assert.equal(game.phase,target);return game; } /** Finish preceding native opportunities through their real Ready actions. * Basic and an already-open unstamped Revival retain concurrent timing. */ export function awaitSpiceBankerIncomeRevivalActor(state:Game,actor:string):Game { let game=state; assert.equal(game.phase,4); for(let i=0;game.revivalOrder&&i<200;i++) { assert.ok(game.revivalOrder.remaining.includes(actor),'The requested native Revival opportunity must still be pending.'); if(game.phaseOpening||game.response||game.decision) game=step(game); else if(game.revivalOrder.remaining[0]===actor) return game; else game=applyAction(game,game.revivalOrder.remaining[0],{type:'ready'}); } assert.ok(!game.revivalOrder,'The native Revival actor must be reached before continuing.'); return game; } /** Reuse real custody first; any explicit staging transfers the canonical card * out of the remaining deck, without duplicating or redealing starting hands. */ function stageHeldCard(game:Game,player:Game['players'][number],matches:(card:Card)=>boolean):Card { const held=player.hand.find(matches); if(held) return held; const index=game.deck.findIndex(matches);assert.ok(index>=0,'No matching real card remains available for conserved staging.'); const card=game.deck.splice(index,1)[0];player.hand.push(card);return card; } /** Independent native quotes for these classic support battles. Collection is * quoted on the resolved physical board, with its pre-collection spice restored, * so ordinary stronghold/desert income is not mistaken for a smaller debit. */ export function quoteSpiceBankerIncomeBattleEconomics(before:Game,paid:Game) { const battle=before.battle;assert.ok(battle?.revealed); const combatant=(id:string,opponentId:string):ResolutionCombatant=>{ const player=before.players.find(p=>p.id===id)!; const opponent=before.players.find(p=>p.id===opponentId)!; const groups=Object.entries(player.forces).filter(([key])=>key.startsWith(`${battle.territory}:`)); const elite=groups.reduce((sum,[key])=>sum+(player.elites?.forces[key]??0),0); return { id,faction:player.faction,ally:player.ally,spice:player.spice,hand:player.hand, plan:battle.plans[id],leader:player.leaders.find(l=>l.id===battle.plans[id].leader), leaderSkills:before.leaderSkills!.assignments.filter(a=>{ const trainer=before.players.find(p=>p.id===a.owner)!.leaders.find(l=>l.id===a.leader)!; return (trainer.capturedBy??a.owner)===id; }).map(a=>({skill:a.skill,leader:a.leader,faceUp:a.owner===id&&!battle.leaderSkillHidden?.[id],captured:a.owner!==id})), occupiedStrongholds:leaderSkillStrongholdCount(before,player), forces:{normal:groups.reduce((sum,[,amount])=>sum+amount,0)-elite,elite,normalFixedHalf:false, eliteStrength:!before.advanced||battle.eliteBlocked?.includes(id)||(player.faction==='emperor'&&opponent.faction==='fremen')?1:2, freeSupport:!before.advanced||(player.faction==='fremen'&&!battle.fremenSupportBlocked)}, }; }; const resolution=quoteBattleResolution({ advanced:before.advanced,turn:before.turn,territory:battle.territory, attacker:combatant(battle.attacker,battle.defender),defender:combatant(battle.defender,battle.attacker), voters:viewGame(before,before.host).battle!.traitorVoters.map(id=>({ id,beneficiary:[battle.attacker,battle.defender].includes(id)?id:before.players.find(p=>p.id===id)!.ally!, called:battle.traitorCalls[id]??false,traitors:before.players.find(p=>p.id===id)!.traitors, })), participants:before.players, physicalCards:[...before.deck,...before.discard,...before.players.flatMap(p=>p.hand)], pendingAuditorPresent:false,pendingRetentionPresent:false, }); const spice={...before.spice}; const smuggler=paid.lastBattleContext?.smugglerCollection; if(smuggler?.key&&smuggler.stage==='collected') spice[smuggler.key]=smuggler.before-smuggler.amount; const sandmaster=paid.lastBattleContext?.sandmaster; if(sandmaster) spice[sandmaster.key]=sandmaster.after; const nativeCollection=paid.phase===7?quoteSpiceCollection({...paid,spice,players:paid.players.map(p=>({...p,spice:0}))}).receipts:[]; return {resolution,nativeCollection}; } /** Genuine setup and native phase transitions; only the conserved source force, * casualty and funding position is staged. Every debit/effect uses its native action. */ export function createSpiceBankerIncomeFixture(options:SpiceBankerIncomeFixtureOptions={}):SpiceBankerIncomeFixture { const kind=options.kind??'auction';let game:Game; if(options.initial) game=structuredClone(options.initial); else { const ids=options.seatIds??['p0','p1','p2','p3']; const bankerFaction=options.bankerFaction??'atreides'; const payerFaction=options.payerFaction??(bankerFaction==='harkonnen'?'atreides':'harkonnen'); game=createGame('BANKERINCOME',newPlayer(ids[0],'Banker',bankerFaction),options.advanced??false); if(payerFaction!==bankerFaction) joinGame(game,newPlayer(ids[1],'Payer',payerFaction)); if((options.emperor||kind==='emperor-extra-revival')&&!game.players.some(p=>p.faction==='emperor')) joinGame(game,newPlayer(ids[game.players.length],'Emperor','emperor')); if((options.guild||kind==='cross-shipment'||kind==='return-shipment')&&!game.players.some(p=>p.faction==='guild')) joinGame(game,newPlayer(ids[game.players.length],'Guild','guild')); if(options.allySplit&&!game.players.some(p=>p.faction==='emperor'||p.faction==='guild')) joinGame(game,newPlayer(ids[game.players.length],'Donor','beneGesserit')); if(game.players.length===1) { const opponentFaction=bankerFaction==='harkonnen'?'atreides':'harkonnen'; joinGame(game,newPlayer(ids[1],'Opponent',opponentFaction)); } if(options.revivalBeforeBanker) { assert.ok(game.advanced); const preceding=game.players.find(p=>p.faction===options.revivalBeforeBanker)!; const banker=game.players.find(p=>p.faction===bankerFaction)!; assert.ok(preceding&&banker&&preceding.id!==banker.id); // First Storm0 ends at1; the native next card1..6 ends at2..7. // Original printed circles14/17 keep the future skilled payer before // the original Banker without reopening an already completed turn. game=applyAction(game,preceding.id,{type:'seatPosition',position:5}); game=applyAction(game,banker.id,{type:'seatPosition',position:6}); } } game=setup(game); const assignment=game.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!; const owner=assignment.owner,leader=assignment.leader; let actor=options.payerFaction?game.players.find(p=>p.faction===options.payerFaction)!.id: options.ownerIsPayer?owner:game.players.find(p=>p.id!==owner&&p.faction==='harkonnen')?.id??game.players.find(p=>p.id!==owner)!.id; if(kind==='kh-revival') actor=game.players.find(p=>p.faction==='atreides')!.id; let paymentActor:string|undefined; let paymentAction:Action|undefined;let amount=options.amount??4; const split=options.allySplit??0; if(kind==='auction') { game=phase(game,3); assert.ok(game.auction); const payer=game.players.find(p=>p.id===actor)!; if(options.stageKarama) stageHeldCard(game,payer,c=>c.effect==='karama'); if(options.stageIncomeCounter) stageHeldCard(game,game.players.find(p=>p.id===owner)!,c=>c.effect==='karama'); for(let i=0;game.auction!.active!==actor&&i<10;i++) game=applyAction(game,game.auction!.active,{type:'passBid'}); if(split) { const donor=game.players.find(p=>p.id!==actor&&p.id!==owner)!;const payer=game.players.find(p=>p.id===actor)!; payer.ally=donor.id;donor.ally=actor;game=applyAction(game,donor.id,{type:'pledgeAid',amount:split}); } game=applyAction(game,actor,{type:'bid',amount,allyPayment:split}); for(let i=0;i<10;i++) { const auction=game.auction!; const next=quoteNormalAuctionNext({...auction,order:game.order, eligible:game.players.filter(p=>p.hand.lengthp.id), passed:[...auction.passed,auction.active]}); if(next.kind==='payment') { if(options.stageKarama) { game=applyAction(game,auction.active,{type:'passBid'}); assert.equal(game.decision?.kind,'auctionPayment'); paymentAction={type:'decision',karama:false}; } else { paymentActor=auction.active;paymentAction={type:'passBid'}; } break; } game=applyAction(game,auction.active,{type:'passBid'}); } assert.ok(paymentAction,'The original winning lot must reach its native payment step.'); } else if(kind==='force-revival'||kind==='leader-revival'||kind==='kh-revival'||kind==='emperor-extra-revival') { if(kind==='leader-revival'||kind==='kh-revival') { if(kind==='kh-revival') { assert.ok(game.advanced);assert.equal(game.players.find(p=>p.id===actor)!.faction,'atreides'); // Seven actual native force casualties unlock KH before its later battle. game=stageSpiceBankerIncomeTrainerBattle(game,{trainerOwner:actor,forceCasualties:7}); } game=stageSpiceBankerIncomeTrainerBattle(game,{trainerOwner:actor,death:true,kwisatz:kind==='kh-revival'}); game=advanceSpiceBankerIncomeToMentat(game); } game=phase(game,4); const beneficiary=kind==='emperor-extra-revival' ?game.players.find(p=>p.faction!=='emperor'&&p.id!==owner)!.id:actor; game=awaitSpiceBankerIncomeRevivalActor(game,beneficiary); const payer=game.players.find(p=>p.id===actor)!; if(kind==='force-revival') { payer.reserves-=3;payer.tanks+=3;paymentAction={type:'revive',amount:3};amount=forceRevivalQuote(game,payer,3).cost; } else if(kind==='leader-revival') { const target=payer.leaders.find(l=>l.strength===(options.amount??4))!;assert.ok(target,'No native leader at requested price.'); assert.ok(payer.leaders.every(l=>l.dead)); paymentAction={type:'reviveLeader',leader:target.id};amount=target.strength; } else if(kind==='kh-revival') { assert.equal(payer.faction,'atreides');assert.ok(game.advanced); assert.ok(payer.leaders.every(l=>l.dead));assert.equal(payer.kwisatz?.dead,true); paymentAction={type:'reviveKwisatz'};amount=2; } else { const emperor=game.players.find(p=>p.faction==='emperor')!;const recipient=game.players.find(p=>p.id!==emperor.id&&p.id!==owner)!; emperor.ally=recipient.id;recipient.ally=emperor.id;recipient.reserves-=2;recipient.tanks+=2; actor=emperor.id;paymentAction={type:'emperorRevival',amount:2};amount=4; } } else if(kind==='shipment'||kind==='cross-shipment'||kind==='return-shipment') { game=phase(game,5); if(kind!=='shipment') actor=game.players.find(p=>p.faction==='guild')!.id; for(let i=0;game.active!==actor&&i<10;i++) game=applyAction(game,game.active!,{type:'endMovement'}); let payer=game.players.find(p=>p.id===actor)!; if(options.stageIncomeCounter) stageHeldCard(game,game.players.find(p=>p.id===owner)!,c=>c.effect==='karama'); const destination=TERRITORIES.find(t=>t.type==='stronghold'&&!t.sectors.includes(game.storm)&&game.players.every(p=>!Object.keys(p.forces).some(k=>k.startsWith(`${t.id}:`))))!; assert.ok(destination); if(split) {const donor=game.players.find(p=>p.id!==actor&&p.id!==owner)!;payer.ally=donor.id;donor.ally=actor;game=applyAction(game,donor.id,{type:'pledgeAid',amount:split});payer=game.players.find(p=>p.id===actor)!;} if(kind==='shipment') paymentAction={type:'ship',territory:destination.id,sector:destination.sectors[0],amount:payer.faction==='guild'?amount*2:amount,allyPayment:split}; else { const origin=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm))!; payer.reserves-=amount*2;payer.forces[`${origin.id}:${origin.sectors[0]}`]=(payer.forces[`${origin.id}:${origin.sectors[0]}`]??0)+amount*2; paymentAction={type:'guildShip',forces:{[`${origin.id}:${origin.sectors[0]}`]:amount*2},territory:kind==='return-shipment'?'reserves':destination.id,sector:destination.sectors[0],allyPayment:split}; } } else { assert.ok(game.advanced,'Paid support requires Advanced combat.');game=phase(game,5); const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&game.players.every(p=>!Object.keys(p.forces).some(k=>k.startsWith(`${t.id}:`))))!; for(const id of [owner,actor]) {const p=game.players.find(p=>p.id===id)!;p.reserves-=4;p.forces[`${territory.id}:${territory.sectors[0]}`]=4;} let weapon:string|undefined; if(options.trainerDies) { weapon=stageHeldCard(game,game.players.find(p=>p.id===actor)!,c=>c.kind==='projectile').id; } while(game.phase===5) game=step(game); game=applyAction(game,game.active!,{type:'chooseBattle',territory:territory.id,target:game.active===owner?actor:owner}); for(let i=0;(game.response||game.decision||game.battle?.preparation)&&i<100;i++) { if(game.decision?.kind==='leaderSkillVisibility'&&game.decision.player===owner&&(options.trainerSelected||options.trainerHidden)) game=applyAction(game,owner,{type:'leaderSkillVisibility',event:game.decision.event,hide:true}); else game=step(game); } for(const id of [game.battle!.attacker,game.battle!.defender]) { const p=game.players.find(p=>p.id===id)!; const nativeTrainer=game.leaderSkills!.assignments.find(a=>a.owner===id)?.leader; game=applyAction(game,id,{type:'battlePlan', leader:id===owner&&options.trainerSelected?leader:p.leaders.find(l=>l.id!==nativeTrainer&&!l.dead)!.id, dial:id===actor?amount:0,support:id===actor?amount:0,...(id===actor&&weapon?{weapon}:{}), }); } for(let i=0;(game.response||game.decision)&&i<100;i++) game=step(game); game=applyAction(game,owner,{type:'traitorCall',call:false});paymentAction={type:'traitorCall',call:false}; } assert.ok(paymentAction); const bankLegs=[{payer:actor,amount:amount-split},...(split?[{payer:game.players.find(p=>p.id===actor)!.ally!,amount:split}]:[])]; return {game,beforePayment:structuredClone(game),actor:paymentActor??actor,payer:actor,paymentAction,owner,leader,event:game.battle?.event??game.code,kind,amount,bankLegs}; } /** Conserved trainer battle, resolved through native casualty, death, skill-return * and capture actions. The optional trainerOwner selects an existing assignment; * no assigned trainer or earned-currency record is directly edited. */ export function stageSpiceBankerIncomeTrainerBattle(state:Game,options:{death?:boolean;capture?:boolean;reassignToOpponent?:boolean;trainerOwner?:string;forceCasualties?:number;kwisatz?:boolean}={}):Game { let game=phase(structuredClone(state),5); const assignment=game.leaderSkills!.assignments.find(a=>options.trainerOwner?a.owner===options.trainerOwner:a.skill==='spice-banker')!;assert.ok(assignment); const owner=game.players.find(p=>p.id===assignment.owner)!; const enemy=game.players.find(p=>p.faction==='harkonnen'&&p.id!==owner.id)??game.players.find(p=>p.id!==owner.id)!; const enemyTrainer=game.leaderSkills!.assignments.find(a=>a.owner===enemy.id)?.leader; if(options.reassignToOpponent) {assert.ok(options.death);assert.ok(enemyTrainer);} if(options.capture) {assert.ok(game.advanced);assert.equal(enemy.faction,'harkonnen');} const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&game.players.every(p=>!Object.keys(p.forces).some(k=>k.startsWith(`${t.id}:`))))!; for(const p of [owner,enemy]) { const amount=p.id===owner.id?(options.forceCasualties??2):2; assert.ok(p.reserves>=amount);p.reserves-=amount;p.forces[`${territory.id}:${territory.sectors[0]}`]=amount; } let weapon:string|undefined; if(options.death||options.capture||options.forceCasualties) { weapon=stageHeldCard(game,enemy,c=>options.kwisatz?c.kind==='lasgun':c.kind==='projectile').id; } let ownerWeapon:string|undefined; if(options.reassignToOpponent) { ownerWeapon=stageHeldCard(game,owner,c=>c.kind==='projectile').id; for(const l of enemy.leaders) if(l.id!==enemyTrainer) {l.dead=true;l.deaths=1;} } let ownerDefense:string|undefined; if(options.kwisatz) { assert.ok(game.advanced&&owner.faction==='atreides'&&owner.battleLosses>=7); ownerDefense=stageHeldCard(game,owner,c=>c.kind==='shield').id; } const sacrificial=owner.leaders.find(l=>l.id!==assignment.leader&&!l.dead)!; for(const l of owner.leaders) if(l.id!==assignment.leader&& (options.death||(options.capture&&l.id!==sacrificial.id))) {l.dead=true;l.deaths=1;} while(game.phase===5) game=step(game); const chooser=game.active!;assert.ok([owner.id,enemy.id].includes(chooser)); game=applyAction(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser===owner.id?enemy.id:owner.id}); for(let i=0;(game.response||game.decision||game.battle?.preparation)&&i<100;i++) { if(game.decision?.kind==='leaderSkillVisibility'&& (game.decision.player===owner.id||(options.reassignToOpponent&&game.decision.player===enemy.id))) game=applyAction(game,game.decision.player,{type:'leaderSkillVisibility',event:game.decision.event,hide:!!options.death}); else game=step(game); } for(const id of [game.battle!.attacker,game.battle!.defender]) { const p=game.players.find(p=>p.id===id)!; game=applyAction(game,id,{type:'battlePlan',dial:0,support:0, leader:id===owner.id?(options.death?assignment.leader:sacrificial.id): options.reassignToOpponent?enemyTrainer:p.leaders.find(l=>!l.dead&&l.id!==game.leaderSkills!.assignments.find(a=>a.owner===id)?.leader)!.id, ...(id===enemy.id&&weapon?{weapon}:{}),...(id===owner.id&&ownerWeapon?{weapon:ownerWeapon}:{}), ...(id===owner.id&&ownerDefense?{defense:ownerDefense,kwisatz:true}:{}), }); } for(let i=0;game.battle&&i<100;i++) { const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next); const finalCall=options.death&&next.action.type==='traitorCall'&& viewGame(game,game.host).battle!.traitorVoters.every(id=>id===next.actor||game.battle!.traitorCalls[id]!==undefined); if(finalCall) { const descriptor=Object.getOwnPropertyDescriptor(crypto,'getRandomValues'); const returned=options.reassignToOpponent?2:1; const size=game.leaderSkills!.deck.length+returned;let roll=0; crypto.getRandomValues=(array:T):T=>{ assert.ok(array);const values=new Uint32Array(array.buffer,array.byteOffset,array.byteLength/4); for(let j=0;jp.id===owner.id)!.leaders.find(l=>l.id===assignment.leader)!.capturedBy,enemy.id); game=applyAction(game,enemy.id,{type:'decision',mode:'keep'});break; } else game=step(game); } } if(options.death) assert.equal(game.players.find(p=>p.id===owner.id)!.leaders.find(l=>l.id===assignment.leader)!.dead,true); if(options.forceCasualties) assert.ok(game.players.find(p=>p.id===owner.id)!.battleLosses>=options.forceCasualties); if(options.kwisatz) assert.equal(game.players.find(p=>p.id===owner.id)!.kwisatz?.dead,true); return game; } export type SpiceBankerIncomeReassignmentFixture = { beforeRevival:Game;game:Game;actor:string;leader:string;originalOwner:string;revivalAction:Action; }; /** Real original payment -> mutual skilled deaths -> native Mentat -> next-turn * normal revival. The two-card offer is drawn only by its original native action. */ export function createSpiceBankerIncomeReassignmentFixture(state:Game):SpiceBankerIncomeReassignmentFixture { const originalOwner=state.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!.owner; const actor=state.players.find(p=>p.faction==='harkonnen'&&p.id!==originalOwner)!.id; const leader=state.leaderSkills!.assignments.find(a=>a.owner===actor)!.leader; let game=stageSpiceBankerIncomeTrainerBattle(state,{death:true,reassignToOpponent:true}); game=advanceSpiceBankerIncomeToMentat(game); game=phase(game,4); game=awaitSpiceBankerIncomeRevivalActor(game,actor); const trainer=game.players.find(p=>p.id===actor)!.leaders.find(l=>l.id===leader)!; assert.ok(trainer.dead&&game.players.find(p=>p.id===actor)!.spice>=trainer.strength); assert.ok(game.leaderSkills!.deck.slice(0,2).includes('spice-banker'),'The actual singleton return shuffle must supply the native two-card offer.'); const revivalAction:Action={type:'reviveLeader',leader}; return {beforeRevival:structuredClone(game),game,actor,leader,originalOwner,revivalAction}; }