Connect normal Spice Banker deferred bank income

Fresh classic banker-income profile preserves actual bank legs, physical once-phase use and original-faction atomic Mentat collection. Provisional earned custody stays explicit; public/old/combined modes unchanged. Final check: types/lint and 6487 offline cases; focused 140; guide 10; build; native18/four legal budgets; two original six-seat CLI/human390px rooms and82 saved seats25rooms. Final two reviews no findings. Local HTTP49/55 six known upstream503 failures, not green. No reset or deployed claim.
This commit is contained in:
Quintine committed 2026-10-02 06:45:21 +10:00
1 parent 13c46450a9
commit 6785e622a7
18 files changed
+2767 -13

No files matched your search

+386
View File
@@ -0,0 +1,386 @@
import assert from 'node:assert/strict';
import {applyAction,createGame,handLimit,initializeSpiceBankerIncomeGameForAudit,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;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<SpiceBankerIncomeFixtureOptions['kind']>;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.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==='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!==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 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.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<T>(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=<V extends ArrayBufferView|null>(array:V):V=>{
assert.ok(array);const values=new Uint32Array(array.buffer,array.byteOffset,array.byteLength/4);
for(let i=0;i<values.length;i++) values[i]=shuffleIndex--===index?0:0xffffffff;
return array;
};
try {return initialize();}
finally {if(descriptor) Object.defineProperty(crypto,'getRandomValues',descriptor);else Reflect.deleteProperty(crypto,'getRandomValues');}
}
/** Only the first fresh native shuffle is deterministic. An admitted setup's
* existing deck, starting hands and offers are never redealt or reordered. */
function setup(initial:Game):Game {
let game=structuredClone(initial);
if(game.status==='lobby') {
for(const p of game.players) if(!p.ready) game=applyAction(game,p.id,{type:'ready'});
game=withSpiceBankerIncomeSetupRandomness(()=>initializeSpiceBankerIncomeGameForAudit(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;
}
/** 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));
}
}
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.length<handLimit(p)).map(p=>p.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 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=<T extends ArrayBufferView|null>(array:T):T=>{
assert.ok(array);const values=new Uint32Array(array.buffer,array.byteOffset,array.byteLength/4);
for(let j=0;j<values.length;j++) values[j]=roll++===0?0:
options.reassignToOpponent&&roll===2?Math.ceil(0x100000000/(size-1)):0xffffffff;
return array;
};
try {game=applyAction(game,next.actor,next.action);}
finally {if(descriptor) Object.defineProperty(crypto,'getRandomValues',descriptor);else Reflect.deleteProperty(crypto,'getRandomValues');}
} else game=applyAction(game,next.actor,next.action);
}
if(options.capture) {
for(let i=0;(game.response||game.decision)&&i<100;i++) {
if(game.decision?.kind==='captureOffer') game=applyAction(game,enemy.id,{type:'decision',accept:true});
else if(game.decision?.kind==='capturedLeader') {
assert.equal(game.players.find(p=>p.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);
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};
}
+147
View File
@@ -805,3 +805,150 @@ void test('Harkonnen Betrayal CLI backs up every room and seat privately before
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, 5, 6]) {
void test(`Banker income ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry conserves the original base and skill inventories without changing credentials`, t => {
const { db } = fixture(t);
const lobby = harkonnenLobby(advanced, seats);
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(lobby), lobby.code);
const credentials = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'banker-income'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
startPrototypeRoom(db, lobby.code, 7, 'banker-income');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(lobby.code)!.state as string) as Game;
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.spiceBankerIncomePreview, true);
assert.equal(saved.advanced, advanced);
assert.equal(saved.host, lobby.host);
assert.deepEqual(saved.playerPositions, lobby.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
const stock = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand)];
assert.equal(stock.length, 33);
assert.equal(new Set(stock.map(card => card.id)).size, 33);
assert.ok(saved.leaderSkills);
const skills = [...saved.leaderSkills.deck, ...Object.values(saved.leaderSkills.offers).flatMap(offer => offer.cards),
...saved.leaderSkills.assignments.map(assignment => assignment.skill)];
assert.deepEqual(skills.sort(), createLeaderSkills(() => 0.5).deck.sort());
for (const player of saved.players) {
const view = viewGame(saved, player.id);
assert.deepEqual(view.spiceBankerIncome, { deferred: [], usedThisPhase: [] });
assert.equal(view.spiceBankerIncomePreview, true);
}
const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
for (const profile of ['banker-income', 'leader-skills', 'nexus'] as const)
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, profile));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
}
void test('Banker income rejects incompatible profiles and public activation without modifying any saved room or credential', t => {
const { db } = fixture(t);
const native = harkonnenLobby();
const changes: ((game: Game) => void)[] = [
game => { game.players[0].ready = false; },
game => { game.code = 'NOTMATCH'; },
game => { game.players[1].faction = game.players[0].faction; },
...(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const).map(faction =>
(game: Game) => { game.players[1].faction = faction; }),
...(['ix', 'choam', 'ecaz'] as const).map(expansion =>
(game: Game) => { game.expansions = [expansion]; }),
game => { game.discoveryEnabled = true; },
game => { game.ecazTreachery = true; },
game => { game.semutaPreview = true; },
game => { game.nexusCards = { cards: null, phase: null }; },
game => { game.nexusIxianReplacementPreview = true; },
game => { game.nexusIxianBetrayalPreview = true; },
game => { game.nexusHarkonnenBetrayalPreview = true; },
game => { game.kullPreview = true; },
game => { game.nexusKullPreview = true; },
game => { game.guildBetrayalPreview = true; },
game => { game.richeseBetrayalPreview = true; },
game => { game.moritaniAssassinatePreview = true; },
game => { game.spiceBankerIncomePreview = true; },
game => { game.leaderSkills = createLeaderSkills(() => 0.5); },
game => { game.players.splice(1); },
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
];
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
const game = applyAction(native, native.host, { type, enabled: true });
for (const player of game.players) player.ready = true;
return game;
});
for (const game of [...changes.map(change => {
const game = structuredClone(native);
change(game);
return game;
}), ...modules]) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats').all();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'banker-income'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
}
const original = structuredClone(native);
for (const action of [{ type: 'start', profile: 'banker-income' }, { type: 'initializeSpiceBankerIncomeGameForAudit' },
{ type: 'spiceBankerIncome', enabled: true }])
assert.throws(() => applyAction(native, native.host, action));
assert.deepEqual(native, original);
});
void test('Banker income CLI requires a fresh exact-version private backup and preserves native setup, old rooms and credentials', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-banker-income-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const lobby = harkonnenLobby(true, 6);
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100);
const other = harkonnenLobby(false, 2);
other.code = 'KEEPGAME';
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
startPrototypeRoom(db, other.code, 9, 'leader-skills');
for (const player of lobby.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, player.id, 'isolated-test-' + player.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const 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', 'banker-income', '--db', database, '--room', lobby.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const result = invoke(7, output);
assert.equal(result.status, 0, result.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), 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 row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
const saved = JSON.parse(row.state as string) as Game;
assert.equal(row.version, 8);
assert.equal(saved.spiceBankerIncomePreview, true);
assert.equal(saved.status, 'setup');
assert.equal(saved.setupStage, 'prediction');
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
assert.deepEqual(saved.players.map(p => p.hand), lobby.players.map(p => p.hand));
assert.deepEqual(saved.leaderSkills!.offers, {});
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);
});
+794
View File
@@ -0,0 +1,794 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { createLeaderSkills } from '../game/leader-skills';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import type * as Rooms from '../db/rooms';
import { startPrototypeRoom } from '../tools/prototype-room';
import { unitStore } from './fixture-nexus-room-store';
import {
createSpiceBankerIncomeFixture,
createSpiceBankerIncomeReassignmentFixture,
nextSpiceBankerIncomeNativeStep,
quoteSpiceBankerIncomeBattleEconomics,
stageSpiceBankerIncomeTrainerBattle,
withSpiceBankerIncomeSetupRandomness,
type SpiceBankerIncomeFixture,
type SpiceBankerIncomeFixtureOptions as Options,
} from './fixture-spice-banker-income';
type SqlRow = Record<string, SQLOutputValue>;
interface Fixture extends SpiceBankerIncomeFixture {
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
tokens: string[];
auths: SeatAuth[];
code: string;
unrelatedCode: string;
unrelatedToken: string;
protectedRows: Record<string, SqlRow[]>;
stock: string[];
save(game: Game): void;
}
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function records(sqlite: DatabaseSync, excludedRoom?: string) {
const result: Record<string, SqlRow[]> = {};
for (const row of sqlite.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()) {
assert.equal(typeof row.name, 'string');
const table = String(row.name).replaceAll('"', '""');
result[String(row.name)] = sqlite.prepare(`SELECT * FROM "${table}"`).all()
.filter(value => !(row.name === 'rooms' && value.code === excludedRoom));
}
return result;
}
function player(game: Game, id: string) {
const value = game.players.find(seat => seat.id === id);
assert.ok(value);
return value;
}
function physical(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'one physical custodian per original Treachery Card');
return ids;
}
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);
return auth;
}
async function act(f: Fixture, game: Game, actor: string, action: Action) {
await f.restart().act(f.code, await authenticate(f, actor), game.version, action, clock);
if (action.type === 'advanceBots') await f.restart().continueRoomAutomatic(f.code, clock);
return f.restart().readRoom(f.code);
}
async function fixture(t: test.TestContext, options: Options = {}): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const bankerFaction = options.bankerFaction ?? 'atreides';
const made = await store.rooms.createRoom('Original Banker faction', bankerFaction, options.advanced ?? true, []);
const code = made.view.code;
const tokens = [made.token];
const factions = [bankerFaction === 'harkonnen' ? 'atreides' : 'harkonnen', 'beneGesserit'] as const;
for (const faction of [...factions, ...(options.guild ? ['guild'] as const : []),
...(options.emperor || options.kind === 'emperor-extra-revival' ? ['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)));
let lobby = await store.rooms.readRoom(code);
for (const auth of auths) {
await store.rooms.act(code, auth, lobby.version, { type: 'ready' }, clock);
lobby = await store.rooms.readRoom(code);
}
// BG keeps the entry's actual native setup undealt. The fixture consumes that
// original setup; it cannot reset already dealt cards or replace an offer.
withSpiceBankerIncomeSetupRandomness(() => startPrototypeRoom(store.sqlite, code, lobby.version, 'banker-income'));
const setup = await store.rooms.readRoom(code);
const native = createSpiceBankerIncomeFixture({ ...options, initial: setup });
assert.equal(native.beforePayment.code, code);
assert.equal(native.beforePayment.host, lobby.host);
assert.deepEqual(native.beforePayment.playerPositions, lobby.playerPositions);
assert.deepEqual(native.beforePayment.players.map(seat => [seat.id, seat.name, seat.faction]),
lobby.players.map(seat => [seat.id, seat.name, seat.faction]));
const save = (game: Game) => {
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(game), game.version, code).changes, 1);
};
save(native.beforePayment);
const unrelated = await store.rooms.createRoom('Unrelated private lobby', 'guild', false, []);
return { ...store, ...native, code, tokens, auths, save, unrelatedCode: unrelated.view.code,
unrelatedToken: unrelated.token, protectedRows: records(store.sqlite, code), stock: physical(native.beforePayment) };
}
async function restored(f: Fixture, game: Game) {
const before = records(f.sqlite);
for (const auth of f.auths) {
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
assert.deepEqual(view, viewGame(game, auth.playerId));
assert.deepEqual(Object.keys(view.spiceBankerIncome!).sort(), ['deferred', 'usedThisPhase']);
assert.equal(Object.hasOwn(view, 'deck'), false);
assert.equal(Object.hasOwn(view, 'spiceBankerIncomePayment'), false);
const projected = JSON.stringify(view);
const income = JSON.stringify(view.spiceBankerIncome);
for (const receipt of game.spiceBankerIncome!.sources) {
assert.equal(projected.includes(receipt.source.event), false, 'native payment event stays private in the entire view');
assert.equal(projected.includes(receipt.signature), false, 'receipt consistency data stays private in the entire view');
assert.equal(income.includes(JSON.stringify(receipt.source.bankLegs)), false, 'actual payer legs stay server-only');
}
const pendingPayment = game.response?.spiceBankerIncomePayment;
if (pendingPayment) {
assert.equal(view.response!.spiceBankerIncomePayment, undefined);
assert.equal(projected.includes('"spiceBankerIncomePayment":'), false, 'serialized response has no private invoice field');
assert.equal(projected.includes(pendingPayment.event), false, 'pending native invoice event stays private');
assert.equal(projected.includes(pendingPayment.signature), false, 'pending invoice signature stays private');
assert.equal(projected.includes(JSON.stringify(pendingPayment.legs)), false, 'pending own and donor legs stay private');
}
for (const seat of view.players)
if (seat.id !== auth.playerId) assert.equal(seat.hand, undefined);
}
assert.deepEqual(await f.restart().readRoom(f.code), game);
assert.deepEqual(records(f.sqlite), before, 'authenticated projections never award, collect or repair');
assert.deepEqual(records(f.sqlite, f.code), f.protectedRows);
assert.deepEqual(physical(game), f.stock);
}
async function rejected(f: Fixture, game: Game, auth: SeatAuth, action: Action, version = game.version) {
const before = records(f.sqlite);
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
assert.deepEqual(records(f.sqlite), before, 'rejected action changes no currency, card, credential or room');
}
async function settlePayment(f: Fixture, before = f.beforePayment) {
let game = await act(f, before, f.actor, f.paymentAction);
for (let steps = 0; (game.response || game.decision?.kind === 'guildShipment') && steps < 30; steps++) {
const step = nextSpiceBankerIncomeNativeStep(game);
assert.ok(step);
game = await act(f, game, step.actor, step.action);
}
assert.equal(game.response, null);
return game;
}
async function finalCollectionStep(f: Fixture, initial: Game) {
let game = initial;
for (let steps = 0; steps < 500; steps++) {
assert.notEqual(game.phase, 8, 'the original final Collection action must still be pending');
const step = nextSpiceBankerIncomeNativeStep(game);
assert.ok(step, `native continuation is available in phase ${game.phase}`);
if (game.phase === 7 && applyAction(game, step.actor, step.action).phase === 8)
return { game, step };
game = await act(f, game, step.actor, step.action);
}
assert.fail('native Collection completion did not become available');
}
for (const advanced of [false, true]) {
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} original auction pays once, holds unavailable income through restart and collects at actual native Mentat`, async t => {
const f = await fixture(t, { advanced });
const before = f.beforePayment;
assert.ok(before.auction?.bidder);
const payer = before.auction.bidder;
const card = before.auction.cards[before.auction.index];
const payerSpice = player(before, payer).spice;
const ownerSpice = player(before, f.owner).spice;
const paid = await settlePayment(f);
assert.equal(player(paid, payer).spice, payerSpice - 4);
assert.equal(player(paid, payer).hand.filter(held => held.id === card.id).length, 1);
assert.equal(player(paid, f.owner).spice, ownerSpice);
assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
assert.equal(paid.spiceBankerIncome!.sources.filter(source => source.grant).length, 1);
await restored(f, paid);
await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version);
const final = await finalCollectionStep(f, paid);
await restored(f, final.game);
const funds = player(final.game, f.owner).spice;
const done = await act(f, final.game, final.step.actor, final.step.action);
assert.equal(done.phase, 8);
assert.equal(player(done, f.owner).spice, funds + 1);
assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []);
assert.equal(done.spiceBankerIncome!.collections.length, 1);
assert.deepEqual(done.spiceBankerIncome!.collections[0].credits, [{ owner: f.owner, amount: 1 }]);
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(await f.restart().readRoom(f.code), done);
await rejected(f, done, await authenticate(f, final.step.actor), final.step.action, final.game.version);
await restored(f, done);
});
}
for (const options of [{ amount: 3 }, { ownerIsPayer: true }] satisfies Options[]) {
void test(`SQLite original auction ${options.amount === 3 ? 'below four' : 'by Banker owner'} cannot produce normal income`, async t => {
const f = await fixture(t, options);
const before = f.beforePayment;
assert.ok(before.auction?.bidder);
const payer = before.auction.bidder;
const amount = options.amount ?? 4;
const done = await settlePayment(f);
assert.equal(player(done, payer).spice, player(before, payer).spice - amount);
assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []);
assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 0);
await restored(f, done);
});
}
void test('SQLite duplicate original payment race admits one debit, one physical card and one deferred grant', async t => {
const f = await fixture(t);
const before = f.beforePayment;
assert.ok(before.auction?.bidder);
const payer = before.auction.bidder;
const card = before.auction.cards[before.auction.index];
const auth = await authenticate(f, f.actor);
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 {
results = await Promise.allSettled([f.restart(), f.restart()].map(rooms =>
rooms.act(f.code, auth, before.version, f.paymentAction, clock).finally(release)));
} finally { delete f.hooks.beforeWrite; }
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, before.version + 1);
assert.equal(player(done, payer).spice, player(before, payer).spice - 4);
assert.equal(player(done, payer).hand.filter(held => held.id === card.id).length, 1);
assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1);
await restored(f, done);
});
void test('SQLite lost original payment CAS cannot leak a speculative grant or retire the original card', async t => {
const f = await fixture(t);
const before = f.beforePayment;
const competing = structuredClone(before);
competing.version++;
f.hooks.beforeWrite = async () => {
delete f.hooks.beforeWrite;
f.save(competing);
};
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.actor), before.version, f.paymentAction, clock));
assert.deepEqual(await f.restart().readRoom(f.code), competing);
assert.deepEqual(viewGame(competing, f.owner).spiceBankerIncome!.deferred, []);
await restored(f, competing);
const done = await settlePayment(f, competing);
assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1);
await restored(f, done);
});
void test('SQLite racing final Collection readiness and automatic recovery credit the original owner atomically once', async t => {
const f = await fixture(t);
const paid = await settlePayment(f);
const { game, step } = await finalCollectionStep(f, paid);
const funds = player(game, f.owner).spice;
const auth = await authenticate(f, step.actor);
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 {
results = await Promise.allSettled([
f.restart().act(f.code, auth, game.version, step.action, clock).finally(release),
f.restart().act(f.code, auth, game.version, step.action, clock).finally(release),
f.restart().continueRoomAutomatic(f.code, clock),
f.restart().continueRoomAutomatic(f.code, clock),
]);
} finally { delete f.hooks.beforeWrite; }
assert.equal(results.slice(0, 2).filter(result => result.status === 'fulfilled').length, 1);
const done = await f.restart().readRoom(f.code);
assert.equal(done.phase, 8);
assert.equal(done.version, game.version + 1);
assert.equal(player(done, f.owner).spice, funds + 1);
assert.equal(done.spiceBankerIncome!.collections.length, 1);
assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []);
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(await f.restart().readRoom(f.code), done);
await restored(f, done);
});
void test('SQLite foreign credentials, wrong native actor, stale version and forged automatic-income actions cannot mutate payment state', async t => {
const f = await fixture(t);
const before = records(f.sqlite);
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
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.actor, tokenHash: foreign.tokenHash }));
assert.deepEqual(records(f.sqlite), before);
for (const auth of [foreign, { playerId: f.actor, tokenHash: foreign.tokenHash },
await authenticate(f, f.auths.find(auth => auth.playerId !== f.actor)!.playerId)])
await rejected(f, f.beforePayment, auth, f.paymentAction);
const auth = await authenticate(f, f.actor);
await rejected(f, f.beforePayment, auth, f.paymentAction, f.beforePayment.version - 1);
for (const action of [{ type: 'spiceBankerIncome', amount: 1 }, { type: 'collectSpiceBankerIncome' },
{ ...f.paymentAction, actor: f.owner }, { ...f.paymentAction, bankerIncome: 1 },
{ ...f.paymentAction, paymentSource: 'bank' }])
await rejected(f, f.beforePayment, auth, action);
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.actor);
f.protectedRows = records(f.sqlite, f.code);
await assert.rejects(f.restart().readSeatView(f.code, auth));
await rejected(f, f.beforePayment, auth, f.paymentAction);
});
void test('SQLite corrupt earned source, recipient, amount, stamps and collection history fail closed without read or recovery repair', async t => {
const f = await fixture(t);
const paid = await settlePayment(f);
const changes: ((game: Game) => void)[] = [
game => { game.spiceBankerIncome!.sources[0].source.bankLegs[0].amount++; },
game => { game.spiceBankerIncome!.sources[0].source.event += ':forged'; },
game => { game.spiceBankerIncome!.sources[0].grant!.owner = f.payer; },
game => { game.spiceBankerIncome!.sources[0].grant!.stamp += ':reset'; },
game => { game.spiceBankerIncome!.sources.push(structuredClone(game.spiceBankerIncome!.sources[0])); },
game => { game.spiceBankerIncome!.head += ':forged'; },
game => { game.spiceBankerIncome!.signature += ':forged'; },
];
const final = await finalCollectionStep(f, paid);
const collected = await act(f, final.game, final.step.actor, final.step.action);
const corruptCollection = structuredClone(collected);
corruptCollection.spiceBankerIncome!.collections[0].credits[0].amount++;
for (const corrupt of [...changes.map(change => { const game = structuredClone(paid); change(game); return game; }), corruptCollection]) {
f.save(corrupt);
const before = records(f.sqlite);
const snapshot = structuredClone(corrupt);
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.actor), { type: 'ready' });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(records(f.sqlite), before);
assert.deepEqual(corrupt, snapshot);
}
});
void test('SQLite front-shield income cannot fund a native bid before Mentat collection', async t => {
const f = await fixture(t);
let game = await settlePayment(f);
for (let steps = 0; game.phase === 3 && game.auction?.active !== f.owner && steps < 30; steps++) {
const step = nextSpiceBankerIncomeNativeStep(game);
assert.ok(step);
game = await act(f, game, step.actor, step.action);
}
assert.equal(game.phase, 3);
assert.equal(game.auction?.active, f.owner);
assert.deepEqual(viewGame(game, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
assert.equal(player(game, f.owner).hand.some(card => card.effect === 'karama'), false,
'this is an ordinary funded bid, not an authorized free Karama overbid');
await rejected(f, game, await authenticate(f, f.owner),
{ type: 'bid', amount: player(game, f.owner).spice + 1 });
await restored(f, game);
});
for (const guild of [false, true]) {
void test(`SQLite original shipment to ${guild ? 'native Guild' : 'Bank'} preserves its actual recipient and credits only the Bank case`, async t => {
const f = await fixture(t, { kind: 'shipment', guild });
const before = f.beforePayment;
const payer = player(before, f.payer);
const shipped = Number(f.paymentAction.amount ?? before.pendingShipment?.amount);
assert.ok(Number.isSafeInteger(shipped) && shipped > 0);
const recipient = before.players.find(seat => seat.faction === 'guild');
const done = await settlePayment(f);
assert.equal(player(done, f.payer).spice, payer.spice - f.amount);
assert.equal(player(done, f.payer).reserves, payer.reserves - shipped);
assert.equal(player(done, f.payer).shipped, true);
if (f.actor !== f.payer)
assert.equal(player(done, f.actor).reserves, player(before, f.actor).reserves,
'the native executor does not ship the economic payer’s reserves');
if (recipient) assert.equal(player(done, recipient.id).spice, recipient.spice + f.amount);
assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred,
guild ? [] : [{ owner: f.owner, amount: 1 }]);
await restored(f, done);
});
}
void test('SQLite genuine free force revival has zero bank payment and never manufactures income', async t => {
const f = await fixture(t, { kind: 'force-revival' });
const before = f.beforePayment;
const funds = player(before, f.actor).spice;
const tanks = player(before, f.actor).tanks;
const reserves = player(before, f.actor).reserves;
const done = await act(f, before, f.actor, { type: 'revive', amount: 1 });
assert.equal(player(done, f.actor).spice, funds);
assert.equal(player(done, f.actor).tanks, tanks - 1);
assert.equal(player(done, f.actor).reserves, reserves + 1);
assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []);
assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 0);
await restored(f, done);
});
void test('SQLite pending native Guild income keeps its funded own and donor invoice private and rejects invoice corruption without repair', async t => {
const f = await fixture(t, { kind: 'shipment', guild: true, amount: 5, allySplit: 1, stageIncomeCounter: true });
const before = f.beforePayment;
assert.ok(player(before, f.owner).hand.some(card => card.effect === 'karama'),
'an actual held counter keeps the original Guild income window eligible');
const donor = player(before, f.payer).ally;
const guild = before.players.find(seat => seat.faction === 'guild');
assert.ok(donor && guild);
let pending = await act(f, before, f.actor, f.paymentAction);
for (let steps = 0; (pending.decision?.kind === 'guildShipment' ||
pending.response?.kind === 'guildRate') && steps < 30; steps++) {
assert.equal(pending.phase, before.phase, 'only the original shipment’s native windows may continue');
const step = nextSpiceBankerIncomeNativeStep(pending);
assert.ok(step);
pending = await act(f, pending, step.actor, step.action);
}
assert.equal(pending.response?.kind, 'guildIncome');
const invoice = pending.response!.spiceBankerIncomePayment;
assert.ok(invoice);
assert.deepEqual(invoice.legs, [
{ payer: f.payer, amount: 4, recipient: 'player' },
{ payer: donor, amount: 1, recipient: 'player' },
]);
assert.equal(player(pending, f.payer).spice, player(before, f.payer).spice - 4);
assert.equal(player(pending, donor).spice, player(before, donor).spice, 'native donor escrow was paid before declaration');
assert.equal(player(pending, f.payer).reserves, player(before, f.payer).reserves - 5);
assert.equal(player(pending, guild.id).spice, guild.spice, 'original recipient is still awaiting its response');
assert.deepEqual(viewGame(pending, f.owner).spiceBankerIncome!.deferred, []);
await restored(f, pending);
assert.throws(() => viewGame(pending, 'spectator'));
const privateValues: readonly string[] = [invoice.event, invoice.signature, JSON.stringify(invoice.legs)];
for (const seat of pending.players) {
const publicView = viewGame(pending, seat.id);
assert.equal(publicView.response!.spiceBankerIncomePayment, undefined);
assert.equal(JSON.stringify(publicView).includes('"spiceBankerIncomePayment":'), false);
for (const privateValue of privateValues)
assert.equal(JSON.stringify(publicView).includes(privateValue), false);
}
const changes: ((game: Game) => void)[] = [
game => { delete game.response!.spiceBankerIncomePayment; },
game => { game.response!.spiceBankerIncomePayment!.event += ':forged'; },
game => { game.response!.spiceBankerIncomePayment!.signature += ':forged'; },
game => { game.response!.spiceBankerIncomePayment!.legs[0].amount++; },
game => { game.response!.spiceBankerIncomePayment!.legs[1].payer = f.payer; },
game => { game.response!.spiceBankerIncomePayment!.legs[0].recipient = 'bank'; },
game => { game.response!.spiceBankerIncomePayment!.kind = 'auction'; },
];
const continuation = nextSpiceBankerIncomeNativeStep(pending);
assert.ok(continuation);
for (const change of changes) {
const corrupt = structuredClone(pending);
change(corrupt);
f.save(corrupt);
const rows = records(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, continuation.actor), continuation.action);
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(records(f.sqlite), rows, 'corrupt pending invoices cannot be repaired by refresh or automatic recovery');
}
f.save(pending);
let paid = pending;
for (let steps = 0; paid.response && steps < 30; steps++) {
const step = nextSpiceBankerIncomeNativeStep(paid);
assert.ok(step);
paid = await act(f, paid, step.actor, step.action);
}
assert.equal(paid.response, null);
assert.equal(player(paid, guild.id).spice, guild.spice + 5);
assert.equal(player(paid, f.payer).spice, player(pending, f.payer).spice);
assert.deepEqual(player(paid, f.payer).forces, player(pending, f.payer).forces);
assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, []);
await restored(f, paid);
});
void test('SQLite competing original shipment declarations preserve only the winning tariff and delivered group', async t => {
const f = await fixture(t, { kind: 'shipment' });
const before = f.beforePayment;
const auth = await authenticate(f, f.actor);
const actions = [f.paymentAction, { ...f.paymentAction, amount: 5 }];
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 {
results = await Promise.allSettled(actions.map(action =>
f.restart().act(f.code, auth, before.version, action, clock).finally(release)));
} finally { delete f.hooks.beforeWrite; }
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
const winner = results.findIndex(result => result.status === 'fulfilled');
const amount = winner === 0 ? 4 : 5;
const done = await f.restart().readRoom(f.code);
assert.equal(player(done, f.payer).spice, player(before, f.payer).spice - amount);
assert.equal(player(done, f.payer).reserves, player(before, f.payer).reserves - amount);
assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1);
assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
await restored(f, done);
});
for (const fate of ['death', 'capture'] as const) {
void test(`SQLite original earned currency survives actual native trainer ${fate} and collects for the original faction under the local policy`, async t => {
const f = await fixture(t, { advanced: true });
const paid = await settlePayment(f);
const earned = structuredClone(paid.spiceBankerIncome);
// The fixture stages conserved battle pieces, then executes the genuine
// visibility, plans, survival and capture choices; it never edits income.
const changed = stageSpiceBankerIncomeTrainerBattle(paid, { [fate]: true });
const trainer = player(changed, f.owner).leaders.find(leader => leader.id === f.leader);
assert.ok(trainer);
if (fate === 'death') assert.equal(trainer.dead, true);
else assert.equal(trainer.capturedBy, changed.players.find(seat => seat.faction === 'harkonnen')!.id);
assert.deepEqual(changed.spiceBankerIncome, earned);
assert.deepEqual(viewGame(changed, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
f.save(changed);
await restored(f, changed);
const { game, step } = await finalCollectionStep(f, changed);
const funds = game.players.map(seat => [seat.id, seat.spice] as const);
const done = await act(f, game, step.actor, step.action);
for (const [id, spice] of funds)
assert.equal(player(done, id).spice, spice + Number(id === f.owner));
assert.deepEqual(done.spiceBankerIncome!.collections[0].credits, [{ owner: f.owner, amount: 1 }]);
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(await f.restart().readRoom(f.code), done);
await restored(f, done);
});
}
void test('SQLite two distinct native auction purchases retain both original costs but award only once in their phase', async t => {
const f = await fixture(t);
const firstPurchase = f.beforePayment.auction!.cards[f.beforePayment.auction!.index].id;
const firstBonus = f.beforePayment.deck[0].id;
const firstHand = player(f.beforePayment, f.payer).hand.map(card => card.id);
assert.equal(player(f.beforePayment, f.payer).faction, 'harkonnen');
const first = await settlePayment(f);
assert.equal(player(first, f.payer).spice, player(f.beforePayment, f.payer).spice - 4);
assert.notEqual(firstPurchase, firstBonus);
assert.deepEqual(player(first, f.payer).hand.map(card => card.id).sort(),
[...firstHand, firstPurchase, firstBonus].sort());
assert.deepEqual(first.deck.map(card => card.id), f.beforePayment.deck.slice(1).map(card => card.id));
let game = first;
for (let steps = 0; game.auction?.active !== f.payer && steps < 30; steps++) {
const step = nextSpiceBankerIncomeNativeStep(game);
assert.ok(step);
game = await act(f, game, step.actor, step.action);
}
assert.equal(game.phase, 3);
assert.equal(game.auction?.active, f.payer);
const funds = player(game, f.payer).spice;
const hand = player(game, f.payer).hand.map(card => card.id);
const acquired = game.auction!.cards[game.auction!.index].id;
const bonus = game.deck[0].id;
const deck = game.deck.map(card => card.id);
assert.equal(hand.includes(acquired), false);
assert.equal(hand.includes(bonus), false);
assert.notEqual(acquired, firstPurchase, 'the second purchase is a distinct original auction lot');
assert.notEqual(acquired, bonus);
game = await act(f, game, f.payer, { type: 'bid', amount: 4 });
for (let steps = 0; (!player(game, f.payer).hand.some(card => card.id === acquired) ||
game.response?.kind === 'harkonnenBonus') && steps < 30; steps++) {
if (game.decision?.kind === 'auctionPayment')
game = await act(f, game, f.payer, { type: 'decision', karama: false });
else {
const step = nextSpiceBankerIncomeNativeStep(game);
assert.ok(step);
game = await act(f, game, step.actor, step.action);
}
}
assert.equal(player(game, f.payer).spice, funds - 4);
assert.deepEqual(player(game, f.payer).hand.map(card => card.id).sort(), [...hand, acquired, bonus].sort());
assert.deepEqual(game.deck.map(card => card.id), deck.slice(1), 'the actual native bonus leaves its sole deck custodian once');
assert.equal(game.spiceBankerIncome!.sources.length, first.spiceBankerIncome!.sources.length + 1);
assert.equal(game.spiceBankerIncome!.sources.filter(source => source.grant).length, 1);
assert.notEqual(game.spiceBankerIncome!.sources[0].source.event, game.spiceBankerIncome!.sources[1].source.event);
assert.deepEqual(game.spiceBankerIncome!.sources.map(receipt => receipt.source.bankLegs),
[[{ payer: f.payer, amount: 4 }], [{ payer: f.payer, amount: 4 }]]);
assert.deepEqual(viewGame(game, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
await restored(f, game);
});
void test('SQLite one funded auction split between actual payers never pools three and one into a qualifying payment', async t => {
const f = await fixture(t, { amount: 4, allySplit: 1 });
const before = f.beforePayment;
const donor = player(before, f.payer).ally;
assert.ok(donor);
const done = await settlePayment(f);
assert.equal(player(done, f.payer).spice, player(before, f.payer).spice - 3);
assert.equal(player(done, donor).spice, player(before, donor).spice, 'already committed donor escrow is not charged twice');
const source = done.spiceBankerIncome!.sources.at(-1);
assert.ok(source);
assert.deepEqual(source.source.bankLegs, [{ payer: f.payer, amount: 3 }, { payer: donor, amount: 1 }]);
assert.equal(source.grant, null);
assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []);
await restored(f, done);
});
void test('SQLite the actual returned Banker card can be legally revived and assigned to a different faction without transferring original earned currency', async t => {
const f = await fixture(t, { advanced: true });
const paid = await settlePayment(f);
const originalSource = structuredClone(paid.spiceBankerIncome!.sources[0]);
// Parent-approved entropy is scoped to the genuine native mutual-death return
// shuffle. The helper then follows real Mentat and next-turn Revival; it never
// reorders a started deck, fabricates an offer or edits an income receipt.
const lifecycle = createSpiceBankerIncomeReassignmentFixture(paid);
let game = lifecycle.beforeRevival;
assert.notEqual(lifecycle.actor, lifecycle.originalOwner);
assert.equal(game.turn, paid.turn + 1);
assert.equal(game.phase, 4);
assert.deepEqual(game.spiceBankerIncome!.sources[0], originalSource);
assert.deepEqual(game.spiceBankerIncome!.collections[0].credits, [{ owner: lifecycle.originalOwner, amount: 1 }]);
assert.deepEqual(viewGame(game, lifecycle.originalOwner).spiceBankerIncome!.deferred, []);
assert.equal(game.leaderSkills!.assignments.some(assignment => assignment.skill === 'spice-banker'), false);
f.save(game);
await restored(f, game);
const funds = player(game, lifecycle.actor).spice;
const originalFunds = player(game, lifecycle.originalOwner).spice;
const trainer = player(game, lifecycle.actor).leaders.find(leader => leader.id === lifecycle.leader);
assert.ok(trainer?.dead);
const price = trainer.strength;
game = await act(f, game, lifecycle.actor, lifecycle.revivalAction);
for (let steps = 0; game.response && steps < 30; steps++) {
const step = nextSpiceBankerIncomeNativeStep(game);
assert.ok(step);
game = await act(f, game, step.actor, step.action);
}
assert.equal(game.decision?.kind, 'leaderSkillRevival');
assert.equal(player(game, lifecycle.actor).spice, funds - price);
assert.equal(player(game, lifecycle.originalOwner).spice, originalFunds);
assert.equal(player(game, lifecycle.actor).leaders.find(leader => leader.id === lifecycle.leader)!.dead, false);
assert.equal(game.spiceBankerIncome!.sources.at(-1)!.grant, null, 'future skill assignment cannot award for the earlier revival payment');
await restored(f, game);
const event = game.decision!.event;
game = await act(f, game, lifecycle.actor, { type: 'leaderSkill', event, mode: 'draw' });
assert.ok(game.leaderSkills!.offers[lifecycle.actor].cards.includes('spice-banker'));
await restored(f, game);
const beforeSelection = game;
const select: Action = { type: 'leaderSkill', event, skill: 'spice-banker', leader: lifecycle.leader };
game = await act(f, game, lifecycle.actor, select);
assert.deepEqual(game.leaderSkills!.assignments.find(assignment => assignment.skill === 'spice-banker'),
{ owner: lifecycle.actor, leader: lifecycle.leader, skill: 'spice-banker' });
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
...game.leaderSkills!.assignments.map(assignment => assignment.skill)];
assert.deepEqual(skills.sort(), createLeaderSkills(() => 0.5).deck.sort(), 'all fourteen physical skills remain in one custodian');
assert.deepEqual(game.spiceBankerIncome!.sources[0], originalSource);
assert.deepEqual(game.spiceBankerIncome!.collections[0].credits, [{ owner: lifecycle.originalOwner, amount: 1 }]);
assert.equal(player(game, lifecycle.actor).spice, funds - price);
assert.equal(player(game, lifecycle.originalOwner).spice, originalFunds);
assert.deepEqual(viewGame(game, lifecycle.actor).spiceBankerIncome!.deferred, []);
await rejected(f, game, await authenticate(f, lifecycle.actor), select, beforeSelection.version);
await rejected(f, game, await authenticate(f, lifecycle.actor), select);
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(await f.restart().readRoom(f.code), game);
await restored(f, game);
});
for (const kind of ['force-revival', 'leader-revival', 'kh-revival', 'emperor-extra-revival'] as const) {
void test(`SQLite actual paid ${kind} preserves native payer, revived beneficiary and allowance while retaining only qualifying bank income`, async t => {
const f = await fixture(t, { kind, advanced: true,
...(kind === 'force-revival' ? { payerFaction: 'emperor', emperor: true } : {}),
...(kind === 'kh-revival' ? { bankerFaction: 'harkonnen', payerFaction: 'atreides' } : {}) });
const before = f.beforePayment;
const payer = player(before, f.payer);
const beneficiary = kind === 'emperor-extra-revival' ? player(before, payer.ally!) : payer;
const trainer = player(before, f.owner).leaders.find(leader => leader.id === f.leader);
assert.ok(trainer && !trainer.dead && !trainer.capturedBy);
assert.notEqual(f.owner, payer.id, 'these are another real payer’s bank payment');
const paid = await settlePayment(f);
const revived = player(paid, beneficiary.id);
assert.equal(player(paid, payer.id).spice, payer.spice - f.amount);
assert.equal(player(paid, f.owner).spice, player(before, f.owner).spice);
if (kind === 'force-revival') {
assert.equal(payer.faction, 'emperor', 'native free-one revival leaves two paid forces');
assert.equal(f.amount, 4);
assert.equal(revived.reserves, beneficiary.reserves + 3);
assert.equal(revived.tanks, beneficiary.tanks - 3);
assert.equal(revived.revived, beneficiary.revived + 3);
assert.equal(revived.freeForcesRevived, (beneficiary.freeForcesRevived ?? 0) + 1);
} else if (kind === 'leader-revival') {
const target = beneficiary.leaders.find(leader => leader.id === f.paymentAction.leader);
assert.ok(target?.dead);
assert.equal(revived.leaders.find(leader => leader.id === target.id)!.dead, false);
assert.equal(revived.leaderRevived, true);
assert.equal(paid.decision?.kind, 'leaderSkillRevival');
} else if (kind === 'kh-revival') {
assert.equal(f.amount, 2, 'KH is its actual native two-spice price, never a fabricated four');
assert.equal(beneficiary.kwisatz?.dead, true);
assert.equal(revived.kwisatz?.dead, false);
assert.equal(revived.kwisatz?.revivalCycle, Math.max(revived.revivalCycle, beneficiary.kwisatz!.revivalCycle ?? 1) + 1);
assert.equal(revived.leaderRevived, true);
} else {
assert.notEqual(payer.id, beneficiary.id);
assert.equal(payer.faction, 'emperor');
assert.equal(revived.spice, beneficiary.spice, 'the revived ally does not pay the Emperor’s debit');
assert.equal(revived.reserves, beneficiary.reserves + 2);
assert.equal(revived.tanks, beneficiary.tanks - 2);
assert.equal(revived.revived, beneficiary.revived, 'extra revivals do not consume the normal allowance');
assert.equal(revived.freeForcesRevived, beneficiary.freeForcesRevived);
assert.equal(paid.emperorExtra[beneficiary.id], (before.emperorExtra[beneficiary.id] ?? 0) + 2);
}
const source = paid.spiceBankerIncome!.sources.at(-1);
assert.ok(source);
assert.equal(paid.spiceBankerIncome!.sources.length, before.spiceBankerIncome!.sources.length + 1);
assert.equal(source.source.kind, kind);
assert.equal(source.source.turn, before.turn);
assert.equal(source.source.phase, 4);
assert.deepEqual(source.source.bankLegs, [{ payer: payer.id, amount: f.amount }]);
assert.equal(source.source.trainer?.resolved, true, 'the source authority denotes committed native payment');
assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred,
kind === 'kh-revival' ? [] : [{ owner: f.owner, amount: 1 }]);
await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version);
await restored(f, paid);
});
}
for (const options of [
{ trainerSelected: true, amount: 4, expected: 1 },
{ trainerSelected: true, trainerDies: true, amount: 4, expected: 0 },
{ trainerHidden: true, amount: 4, expected: 0 },
{ amount: 3, expected: 0 },
]) {
void test(`SQLite resolved native support preserves payment and obeys trainer survival, concealment and threshold ${JSON.stringify(options)}`, async t => {
const { expected, ...native } = options;
const f = await fixture(t, { kind: 'battle-support', advanced: true, ...native });
const before = f.beforePayment;
assert.ok(before.battle?.revealed);
assert.equal(before.battle.plans[f.payer].support, options.amount);
assert.deepEqual(viewGame(before, f.owner).spiceBankerIncome!.deferred, []);
const paid = await settlePayment(f);
const trainer = player(paid, f.owner).leaders.find(leader => leader.id === f.leader);
assert.ok(trainer);
assert.equal(trainer.dead, 'trainerDies' in options && options.trainerDies);
assert.equal(paid.lastBattleContext?.event, before.battle.event);
assert.equal(paid.lastBattleContext?.result, 'normal');
const economics = quoteSpiceBankerIncomeBattleEconomics(before, paid);
const support = economics.resolution.payments.find(payment => payment.player === f.payer);
assert.ok(support);
assert.equal(support.ownPayment, options.amount, 'the original native support debit is not netted against income');
const bounty = economics.resolution.bounty;
assert.equal(bounty?.amount ?? 0, trainer.dead ? trainer.strength : 0);
if (bounty) assert.equal(bounty.player, f.payer, 'the winner earns the real killed-leader bounty');
for (const id of [f.payer, f.owner]) {
const debit = economics.resolution.payments.find(payment => payment.player === id)!.ownPayment;
const collection = economics.nativeCollection.find(receipt => receipt.player === id);
const nativeIncome = (bounty?.player === id ? bounty.amount : 0) +
(collection ? collection.strongholds + collection.collected : 0);
assert.equal(player(paid, id).spice, player(before, id).spice - debit + nativeIncome,
'original support, native bounty and actual Collection remain separate from deferred Banker income');
}
const source = paid.spiceBankerIncome!.sources.at(-1);
assert.ok(source);
assert.equal(source.source.kind, 'battle-support');
assert.deepEqual(source.source.bankLegs, [{ payer: f.payer, amount: options.amount }]);
if (trainer.dead) assert.equal(source.source.trainer, null);
else {
assert.equal(source.source.trainer?.resolved, true);
assert.equal(source.source.trainer?.selected, 'trainerSelected' in options && options.trainerSelected);
assert.equal(source.source.trainer?.faceUp, !('trainerSelected' in options || 'trainerHidden' in options));
}
assert.equal(source.grant?.amount ?? 0, expected);
assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred,
expected ? [{ owner: f.owner, amount: 1 }] : []);
await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version);
await restored(f, paid);
});
}
void test('SQLite Emperor self-purchase actually pays the Bank, retains its original card and auction counter, and collects deferred income at native Mentat once', async t => {
const f = await fixture(t, { emperor: true, payerFaction: 'emperor' });
const before = f.beforePayment;
const emperor = player(before, f.payer);
assert.equal(emperor.faction, 'emperor');
assert.notEqual(emperor.id, f.owner);
assert.ok(before.auction);
const card = before.auction.cards[before.auction.index];
const paid = await settlePayment(f);
assert.equal(player(paid, emperor.id).spice, emperor.spice - 4, 'self purchase is a real bank debit, not self-recipient income');
assert.equal(player(paid, emperor.id).hand.filter(held => held.id === card.id).length, 1);
assert.equal(paid.auction?.index, before.auction.index + 1);
assert.equal(player(paid, f.owner).spice, player(before, f.owner).spice);
const receipt = paid.spiceBankerIncome!.sources.at(-1);
assert.ok(receipt);
assert.deepEqual(receipt.source.bankLegs, [{ payer: emperor.id, amount: 4 }]);
assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
await restored(f, paid);
const final = await finalCollectionStep(f, paid);
const funds = player(final.game, f.owner).spice;
const collected = await act(f, final.game, final.step.actor, final.step.action);
assert.equal(collected.phase, 8);
assert.equal(player(collected, f.owner).spice, funds + 1);
assert.deepEqual(collected.spiceBankerIncome!.collections.at(-1)!.credits, [{ owner: f.owner, amount: 1 }]);
assert.deepEqual(viewGame(collected, f.owner).spiceBankerIncome!.deferred, []);
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(await f.restart().readRoom(f.code), collected);
await rejected(f, collected, await authenticate(f, final.step.actor), final.step.action, final.game.version);
await restored(f, collected);
});
+304
View File
@@ -0,0 +1,304 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction,viewGame,type Game} from '../game/engine';
import {advanceSpiceBankerIncomeToMentat,createSpiceBankerIncomeFixture,createSpiceBankerIncomeReassignmentFixture,nextSpiceBankerIncomeNativeStep,quoteSpiceBankerIncomeBattleEconomics,stageSpiceBankerIncomeTrainerBattle,type SpiceBankerIncomeFixture,type SpiceBankerIncomeFixtureOptions} from './fixture-spice-banker-income';
import {forceRevivalQuote} from '../game/revival';
function pay(fixture:SpiceBankerIncomeFixture,action=fixture.paymentAction):Game {
let game=applyAction(fixture.game,fixture.actor,action);
for(let i=0;(game.response||game.decision)&&i<100;i++) {
const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);
}
return game;
}
function deferred(game:Game,owner:string):number {
return viewGame(game,owner).spiceBankerIncome!.deferred.find(row=>row.owner===owner)?.amount??0;
}
function nativeCounters(game:Game):Game {
for(let i=0;game.response&&i<100;i++) {
const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);
}
return game;
}
function beforeMentat(state:Game):Game {
let game=state;
for(let i=0;(game.phase!==7||game.phaseOpening||game.response||game.decision)&&i<1000;i++) {
const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);
}
assert.equal(game.phase,7);return game;
}
for(const advanced of [false,true]) for(const amount of [3,4]) {
void test(`normal paid bank auction ${amount}, ${advanced?'Advanced':'Basic'} retains price/card and defers only qualifying income`,()=>{
const fixture=createSpiceBankerIncomeFixture({advanced,amount});
const payer=fixture.game.players.find(p=>p.id===fixture.payer)!;
const card=fixture.game.auction!.cards[fixture.game.auction!.index];
const balance=payer.spice,ownerBalance=fixture.game.players.find(p=>p.id===fixture.owner)!.spice;
const paid=pay(fixture);
assert.equal(paid.players.find(p=>p.id===fixture.payer)!.spice,balance-amount);
assert.equal(paid.players.find(p=>p.id===fixture.payer)!.hand.filter(c=>c.id===card.id).length,1);
assert.equal(paid.players.find(p=>p.id===fixture.owner)!.spice,ownerBalance);
assert.equal(deferred(paid,fixture.owner),amount===4?1:0);
const collection=beforeMentat(paid),nativeBalance=collection.players.find(p=>p.id===fixture.owner)!.spice;
const mentat=advanceSpiceBankerIncomeToMentat(collection);
assert.equal(deferred(mentat,fixture.owner),0);
assert.equal(mentat.players.find(p=>p.id===fixture.owner)!.spice,nativeBalance+(amount===4?1:0));
});
}
for(const kind of ['shipment','force-revival','leader-revival','emperor-extra-revival','cross-shipment','return-shipment','battle-support'] as const) {
void test(`actual ${kind} original native cost and effect commit once with deferred normal Banker income`,()=>{
const fixture=createSpiceBankerIncomeFixture({kind,advanced:true,...(kind==='force-revival'?{payerFaction:'emperor' as const}:{})});
const payerBefore=fixture.game.players.find(p=>p.id===fixture.payer)!;
const balance=payerBefore.spice,forces=payerBefore.reserves,tanks=payerBefore.tanks;
if(kind==='leader-revival') {
assert.ok(payerBefore.leaders.every(l=>l.dead));
assert.equal(fixture.game.leaderSkills!.assignments.some(a=>a.owner===fixture.payer),false);
assert.ok(fixture.game.leaderSkills!.assignments.some(a=>a.owner===fixture.owner&&a.skill==='spice-banker'));
assert.equal(fixture.game.players.find(p=>p.id===fixture.owner)!.leaders.find(l=>l.id===fixture.leader)!.dead,false);
}
const paid=pay(fixture),payer=paid.players.find(p=>p.id===fixture.payer)!;
if(kind==='battle-support') {
const {resolution,nativeCollection}=quoteSpiceBankerIncomeBattleEconomics(fixture.game,paid);
const payment=resolution.payments.find(row=>row.player===fixture.payer)!;
assert.equal(payment.ownPayment,fixture.game.battle!.plans[fixture.payer].support);
assert.deepEqual(paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,[{payer:fixture.payer,amount:payment.ownPayment}]);
const collection=nativeCollection.find(row=>row.player===fixture.payer);
assert.equal(payer.spice,balance-payment.ownPayment+(resolution.bounty?.player===fixture.payer?resolution.bounty.amount:0)
+(collection?collection.strongholds+collection.collected:0));
} else assert.equal(payer.spice,balance-fixture.amount);
assert.equal(deferred(paid,fixture.owner),1);
if(kind==='shipment') assert.equal(payer.reserves,forces-4);
if(kind==='force-revival') {
const quote=forceRevivalQuote(fixture.game,payerBefore,3);
assert.equal(payerBefore.faction,'emperor');assert.equal(quote.free,1);assert.equal(quote.cost,4);
assert.equal(fixture.amount,quote.cost);
assert.equal(payer.reserves,forces+3);assert.equal(payer.tanks,tanks-3);assert.equal(payer.revived,3);
assert.equal(payer.freeForcesRevived,(payerBefore.freeForcesRevived??0)+quote.free);
}
if(kind==='leader-revival') assert.equal(payer.leaders.find(l=>l.id===fixture.paymentAction.leader)!.dead,false);
if(kind==='return-shipment') assert.equal(payer.reserves,forces+8);
const source=paid.spiceBankerIncome!.sources.find(r=>r.grant)!;
assert.equal(source.source.kind,kind==='cross-shipment'||kind==='return-shipment'?'shipment':kind);
const restored=JSON.parse(JSON.stringify(paid)) as Game;
assert.equal(deferred(restored,fixture.owner),1);
assert.equal(restored.players.find(p=>p.id===fixture.payer)!.spice,payer.spice);
});
}
void test('native Harkonnen force revival uses its two-free allowance and real two-spice boundary',()=>{
const fixture=createSpiceBankerIncomeFixture({kind:'force-revival',advanced:true});
const payer=fixture.game.players.find(p=>p.id===fixture.payer)!;
assert.equal(payer.faction,'harkonnen');assert.notEqual(fixture.payer,fixture.owner);
const quote=forceRevivalQuote(fixture.game,payer,3);
assert.equal(quote.free,2);assert.equal(quote.cost,2);assert.equal(fixture.amount,quote.cost);
const paid=pay(fixture),revived=paid.players.find(p=>p.id===fixture.payer)!;
assert.equal(revived.spice,payer.spice-quote.cost);
assert.equal(revived.freeForcesRevived,(payer.freeForcesRevived??0)+quote.free);
assert.equal(revived.reserves,payer.reserves+3);assert.equal(revived.tanks,payer.tanks-3);
assert.deepEqual(paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,[{payer:fixture.payer,amount:quote.cost}]);
assert.equal(deferred(paid,fixture.owner),0);
});
for(const options of [{ownerIsPayer:true},{emperor:true},{amount:4,allySplit:2},{guild:true,kind:'shipment'}] satisfies SpiceBankerIncomeFixtureOptions[]) {
void test(`nonqualifying native payment ${JSON.stringify(options)} does not manufacture bank income`,()=>{
const fixture=createSpiceBankerIncomeFixture(options);const paid=pay(fixture);
assert.equal(deferred(paid,fixture.owner),0);
assert.equal(paid.spiceBankerIncome!.sources.some(r=>r.grant),false);
});
}
void test('Emperor native self-auction pays BANK once and qualifies another living Banker',()=>{
const fixture=createSpiceBankerIncomeFixture({emperor:true,payerFaction:'emperor',amount:4});
const emperor=fixture.game.players.find(p=>p.id===fixture.payer)!;
assert.equal(emperor.faction,'emperor');assert.notEqual(fixture.payer,fixture.owner);
const card=fixture.game.auction!.cards[fixture.game.auction!.index];
const paid=pay(fixture),buyer=paid.players.find(p=>p.id===fixture.payer)!;
assert.equal(buyer.spice,emperor.spice-4);
assert.equal(buyer.hand.filter(c=>c.id===card.id).length,1);
assert.equal(paid.response?.kind==='emperorIncome',false);
assert.equal(deferred(paid,fixture.owner),1);
assert.deepEqual(paid.spiceBankerIncome!.sources.find(r=>r.grant)!.source.bankLegs,[{payer:fixture.payer,amount:4}]);
});
for(const bankerFaction of ['atreides','harkonnen'] as const) {
void test(`native KH explosion death and next-turn two-spice revival never qualify (${bankerFaction} Banker)`,()=>{
const fixture=createSpiceBankerIncomeFixture({kind:'kh-revival',advanced:true,bankerFaction,payerFaction:'atreides'});
const payer=fixture.game.players.find(p=>p.id===fixture.payer)!;
assert.ok(payer.battleLosses>=7);assert.equal(payer.kwisatz?.dead,true);
assert.ok(payer.leaders.every(l=>l.dead));
assert.equal(fixture.game.leaderSkills!.assignments.some(a=>a.owner===payer.id),false);
if(fixture.owner!==fixture.payer) {
assert.ok(fixture.game.leaderSkills!.assignments.some(a=>a.owner===fixture.owner&&a.skill==='spice-banker'));
assert.equal(fixture.game.players.find(p=>p.id===fixture.owner)!.leaders.find(l=>l.id===fixture.leader)!.dead,false);
}
const paid=pay(fixture),revived=paid.players.find(p=>p.id===fixture.payer)!;
assert.equal(revived.spice,payer.spice-2);assert.equal(revived.kwisatz?.dead,false);
assert.equal(revived.leaderRevived,true);assert.equal(deferred(paid,fixture.owner),0);
assert.equal(paid.spiceBankerIncome!.sources.some(r=>r.grant),false);
});
}
void test('native self leader death returns Banker before legal revival, so its paid return grants no income',()=>{
const fixture=createSpiceBankerIncomeFixture({kind:'leader-revival',advanced:true,ownerIsPayer:true});
assert.equal(fixture.payer,fixture.owner);
assert.equal(fixture.game.leaderSkills!.assignments.some(a=>a.skill==='spice-banker'),false);
const payer=fixture.game.players.find(p=>p.id===fixture.payer)!;
const paid=pay(fixture),revived=paid.players.find(p=>p.id===fixture.payer)!;
assert.equal(revived.spice,payer.spice-4);
assert.equal(revived.leaders.find(l=>l.id===fixture.paymentAction.leader)!.dead,false);
assert.equal(deferred(paid,fixture.owner),0);
});
void test('printed Karama auction and free native revival have no positive bank payment',()=>{
const auction=createSpiceBankerIncomeFixture({stageKarama:true});const payer=auction.game.players.find(p=>p.id===auction.payer)!;
const card=payer.hand.find(c=>c.effect==='karama')!;assert.ok(card);const balance=payer.spice;
const free=pay(auction,{type:'decision',karama:true,card:card.id});
assert.equal(free.players.find(p=>p.id===auction.payer)!.spice,balance);
assert.equal(free.players.find(p=>p.id===auction.payer)!.hand.some(c=>c.id===card.id),false);
assert.equal(free.discard.filter(c=>c.id===card.id).length,1);
assert.equal(deferred(free,auction.owner),0);
const revival=createSpiceBankerIncomeFixture({kind:'force-revival'});
const revived=pay(revival,{type:'revive',amount:1});
assert.equal(revived.players.find(p=>p.id===revival.payer)!.spice,revival.game.players.find(p=>p.id===revival.payer)!.spice);
assert.equal(deferred(revived,revival.owner),0);
});
void test('canceled Emperor income goes to BANK without charging buyer twice or losing card',()=>{
const fixture=createSpiceBankerIncomeFixture({emperor:true,stageIncomeCounter:true});
const purchased=fixture.game.auction!.cards[fixture.game.auction!.index];
const emperorBefore=fixture.game.players.find(p=>p.faction==='emperor')!;
let game=applyAction(fixture.game,fixture.actor,fixture.paymentAction);
assert.equal(game.response?.kind,'emperorIncome');
const canceler=game.players.find(p=>p.id===fixture.owner)!;
const card=canceler.hand.find(c=>c.effect==='karama');assert.ok(card);
const balance=game.players.find(p=>p.id===fixture.payer)!.spice;
assert.equal(balance,fixture.game.players.find(p=>p.id===fixture.payer)!.spice-fixture.amount);
game=applyAction(game,canceler.id,{type:'card',card:card.id,mode:'cancel'});game=nativeCounters(game);
assert.equal(game.players.find(p=>p.id===fixture.payer)!.spice,balance);
assert.equal(game.players.find(p=>p.id===fixture.payer)!.hand.filter(c=>c.id===purchased.id).length,1);
assert.equal(game.players.find(p=>p.id===emperorBefore.id)!.spice,emperorBefore.spice);
assert.equal(game.discard.filter(c=>c.id===card.id).length,1);
assert.deepEqual(game.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,fixture.bankLegs);
assert.equal(deferred(game,fixture.owner),1);
});
void test('canceled Guild income uses the actual funded shipment legs, not waived or pooled tariff',()=>{
const fixture=createSpiceBankerIncomeFixture({guild:true,kind:'shipment',stageIncomeCounter:true});
const guildBefore=fixture.game.players.find(p=>p.faction==='guild')!;
let game=applyAction(fixture.game,fixture.actor,fixture.paymentAction);game=nativeCountersUntil(game,'guildIncome');
assert.equal(game.response?.kind,'guildIncome');
const canceler=game.players.find(p=>p.id===fixture.owner)!;
const card=canceler.hand.find(c=>c.effect==='karama');assert.ok(card);
const forces=game.players.find(p=>p.id===fixture.payer)!.forces;
const balance=game.players.find(p=>p.id===fixture.payer)!.spice;
assert.equal(balance,fixture.game.players.find(p=>p.id===fixture.payer)!.spice-fixture.amount);
game=applyAction(game,canceler.id,{type:'card',card:card.id,mode:'cancel'});game=nativeCounters(game);
assert.deepEqual(game.players.find(p=>p.id===fixture.payer)!.forces,forces);
assert.equal(game.players.find(p=>p.id===fixture.payer)!.spice,balance);
assert.equal(game.players.find(p=>p.id===guildBefore.id)!.spice,guildBefore.spice);
assert.equal(game.discard.filter(c=>c.id===card.id).length,1);
assert.deepEqual(game.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,fixture.bankLegs);
assert.equal(deferred(game,fixture.owner),1);
});
function nativeCountersUntil(game:Game,kind:string):Game {
for(let i=0;(game.response&&game.response.kind!==kind||game.decision?.kind==='guildShipment')&&i<100;i++) {
const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);
}
return game;
}
void test('one physical Banker stamps once per phase and permits another native phase payment',()=>{
const fixture=createSpiceBankerIncomeFixture({amount:5,allySplit:1});let game=pay(fixture);
assert.ok(game.auction);
for (;;) {
const auction = game.auction;
assert.ok(auction, 'The second funded native lot must still be offered.');
if (auction.active === fixture.payer) break;
game = applyAction(game, auction.active, {type:'passBid'});
}
const index=game.auction!.index;
game=applyAction(game,fixture.payer,{type:'bid',amount:4});
for(let i=0;(game.auction?.index===index||game.response||game.decision)&&i<100;i++) {
const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);
}
assert.equal(deferred(game,fixture.owner),1);
assert.equal(game.spiceBankerIncome!.sources.filter(r=>r.grant).length,1);
while(game.phase===3) {const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);}
while(game.phaseOpening||game.response||game.decision) {const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);}
assert.equal(game.phase,4);
const payer=game.players.find(p=>p.faction==='beneGesserit')!;payer.reserves-=3;payer.tanks+=3;
game=applyAction(game,payer.id,{type:'revive',amount:3});game=nativeCounters(game);
assert.equal(deferred(game,fixture.owner),2);
assert.equal(new Set(game.spiceBankerIncome!.sources.filter(r=>r.grant).map(r=>r.grant!.stamp)).size,2);
});
for(const lifecycle of ['death','capture'] as const) {
void test(`earned entitlement stays original through genuine native trainer ${lifecycle} and actual Mentat collection`,()=>{
const fixture=createSpiceBankerIncomeFixture({advanced:true});const paid=pay(fixture);
const staged=stageSpiceBankerIncomeTrainerBattle(paid,{[lifecycle]:true});
assert.equal(deferred(staged,fixture.owner),1);
const collection=beforeMentat(staged),balance=collection.players.find(p=>p.id===fixture.owner)!.spice;
const mentat=advanceSpiceBankerIncomeToMentat(collection);
assert.equal(mentat.players.find(p=>p.id===fixture.owner)!.spice,balance+1);
assert.equal(deferred(mentat,fixture.owner),0);
const restored=JSON.parse(JSON.stringify(mentat)) as Game;
for(const p of restored.players) {const view=viewGame(restored,p.id);assert.deepEqual(view.spiceBankerIncome,{deferred:[],usedThisPhase:[]});}
});
}
void test('public front-shield counts never disclose private source/authority and malformed inputs reject immutably',()=>{
const fixture=createSpiceBankerIncomeFixture();const paid=pay(fixture);
assert.equal(paid.phase,fixture.game.phase,'compare use while the native auction phase is still offered');
for(const p of paid.players) assert.deepEqual(viewGame(paid,p.id).spiceBankerIncome,{deferred:[{owner:fixture.owner,amount:1}],usedThisPhase:[fixture.owner]});
const before=JSON.stringify(fixture.game);
assert.throws(()=>applyAction(fixture.game,fixture.owner,{...fixture.paymentAction,actor:fixture.actor}));
assert.equal(JSON.stringify(fixture.game),before);
const corrupt=JSON.parse(JSON.stringify(paid)) as Game;
corrupt.spiceBankerIncome!.sources[0].source.bankLegs[0].amount++;
const corruptBefore=JSON.stringify(corrupt);
assert.throws(()=>viewGame(corrupt,fixture.owner));assert.throws(()=>applyAction(corrupt,fixture.owner,{type:'ready'}));
assert.equal(JSON.stringify(corrupt),corruptBefore);
});
for(const options of [
{trainerSelected:true,expected:1},
{trainerSelected:true,trainerDies:true,expected:0},
{trainerHidden:true,expected:0},
{amount:3,expected:0},
]) {
void test(`battle income follows resolved native trainer eligibility ${JSON.stringify(options)}`,()=>{
const fixture=createSpiceBankerIncomeFixture({kind:'battle-support',advanced:true,...options});
assert.equal(deferred(fixture.game,fixture.owner),0);
const paid=pay(fixture);
assert.equal(deferred(paid,fixture.owner),options.expected);
const {resolution,nativeCollection}=quoteSpiceBankerIncomeBattleEconomics(fixture.game,paid);
const payment=resolution.payments.find(row=>row.player===fixture.payer)!;
assert.equal(payment.ownPayment,fixture.game.battle!.plans[fixture.payer].support);
assert.deepEqual(paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,[{payer:fixture.payer,amount:payment.ownPayment}]);
const bounty=resolution.bounty?.player===fixture.payer?resolution.bounty.amount:0;
const collection=nativeCollection.find(row=>row.player===fixture.payer);
assert.equal(paid.players.find(p=>p.id===fixture.payer)!.spice,
fixture.game.players.find(p=>p.id===fixture.payer)!.spice-payment.ownPayment+bounty
+(collection?collection.strongholds+collection.collected:0));
assert.equal(paid.phase,7);
for(const p of paid.players) assert.deepEqual(viewGame(paid,p.id).spiceBankerIncome!.usedThisPhase,[]);
});
}
void test('deferred automatic spice cannot fund a bid before actual Mentat collection',()=>{
const fixture=createSpiceBankerIncomeFixture({ownerIsPayer:false});const paid=pay(fixture);
assert.equal(deferred(paid,fixture.owner),1);
let game=paid;
while(game.auction!.active!==fixture.owner) game=applyAction(game,game.auction!.active,{type:'passBid'});
const wallet=game.players.find(p=>p.id===fixture.owner)!.spice,before=JSON.stringify(game);
assert.throws(()=>applyAction(game,fixture.owner,{type:'bid',amount:wallet+1}));
assert.equal(JSON.stringify(game),before);
});
void test('a real singleton skill return and different-faction native revival retain old owner custody but use the new eligible owner',()=>{
const fixture=createSpiceBankerIncomeFixture({advanced:true});const paid=pay(fixture);
const lifecycle=createSpiceBankerIncomeReassignmentFixture(paid);
assert.equal(lifecycle.game.spiceBankerIncome!.sources.find(r=>r.grant)!.grant!.owner,fixture.owner);
assert.equal(lifecycle.game.spiceBankerIncome!.collections[0].credits.find(c=>c.owner===fixture.owner)!.amount,1);
let game=applyAction(lifecycle.game,lifecycle.actor,lifecycle.revivalAction);
game=nativeCounters(game);
assert.equal(game.decision?.kind,'leaderSkillRevival');
let offer=game.leaderSkills!.offers[lifecycle.actor];
game=applyAction(game,lifecycle.actor,{type:'leaderSkill',event:offer.event,mode:'draw'});
offer=game.leaderSkills!.offers[lifecycle.actor];assert.ok(offer.cards.includes('spice-banker'));
game=applyAction(game,lifecycle.actor,{type:'leaderSkill',event:offer.event,skill:'spice-banker',leader:lifecycle.leader});
assert.equal(game.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!.owner,lifecycle.actor);
const balance=game.players.find(p=>p.id===lifecycle.actor)!.spice;
game=applyAction(game,fixture.owner,{type:'reviveLeader',leader:fixture.leader});game=nativeCounters(game);
assert.equal(deferred(game,lifecycle.actor),1);
assert.equal(deferred(game,fixture.owner),0);
assert.equal(game.players.find(p=>p.id===lifecycle.actor)!.spice,balance);
assert.equal(game.spiceBankerIncome!.sources.filter(r=>r.grant)[0].grant!.owner,fixture.owner);
assert.equal(game.spiceBankerIncome!.sources.filter(r=>r.grant)[1].grant!.owner,lifecycle.actor);
});
+279
View File
@@ -0,0 +1,279 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
BankerIncomeError,
commitBankerIncome,
commitBankerIncomeCollection,
createBankerIncomeState,
projectBankerIncome,
quoteBankerIncome,
quoteBankerIncomeCollection,
validateBankerIncomeSource,
validateBankerIncomeState,
type BankerIncomeAuthority,
type BankerIncomeCollectionAuthority,
type BankerIncomeContext,
type BankerIncomeReceipt,
type BankerIncomeState,
} from '../game/spice-banker-income';
function context(turn = 1, phase = 3, owner = 'a'): BankerIncomeContext {
return {
turn, phase,
players: [
{ id: 'a', leaders: [{ id: 'emperor-0', dead: false }] },
{ id: 'b', leaders: [{ id: 'guild-0', dead: false }] },
{ id: 'c', leaders: [{ id: 'atreides-0', dead: false }] },
],
assignments: [{ skill: 'spice-banker', leader: owner === 'a' ? 'emperor-0' : 'guild-0', owner }],
};
}
function source(ctx: BankerIncomeContext, event = 'paid-auction', amount = 4, payer = 'b'): BankerIncomeAuthority {
return {
event, kind: 'auction', turn: ctx.turn, phase: ctx.phase,
bankLegs: amount === 0 ? [] : [{ payer, amount }],
trainer: {
assignment: { ...ctx.assignments[0] }, faceUp: true, captured: false,
selected: false, resolved: true, survived: true,
},
};
}
function earn(state: BankerIncomeState, ctx: BankerIncomeContext, authority: BankerIncomeAuthority): BankerIncomeState {
const quote = quoteBankerIncome(state, ctx, authority);
return commitBankerIncome(state, ctx, authority, quote.receipt);
}
function immutableReject(state: BankerIncomeState, action: () => unknown): void {
const before = JSON.stringify(state);
assert.throws(action, BankerIncomeError);
assert.equal(JSON.stringify(state), before);
}
const opening: BankerIncomeCollectionAuthority = { event: 'native-mentat-1', turn: 1, phase: 8 };
void test('another payer must actually pay at least four in one final BANK leg; gain is additional deferred one', () => {
const ctx = context();
for (const [payer, amount, expected] of [['b', 3, 0], ['b', 4, 1], ['b', 19, 1], ['a', 4, 0], ['b', 0, 0]] as const) {
const initial = createBankerIncomeState(ctx);
const authority = source(ctx, `${payer}-${amount}`, amount, payer);
const before = JSON.stringify({ initial, authority, ctx });
const quoted = quoteBankerIncome(initial, ctx, authority);
assert.equal(JSON.stringify({ initial, authority, ctx }), before);
assert.equal(quoted.receipt.grant?.amount ?? 0, expected);
assert.deepEqual(projectBankerIncome(quoted.next, ctx), {
deferred: expected ? [{ owner: 'a', amount: 1 }] : [],
usedThisPhase: expected ? ['a'] : [],
});
assert.deepEqual(quoted.receipt.source.bankLegs, authority.bankLegs);
assert.deepEqual(commitBankerIncome(initial, ctx, authority, quoted.receipt), quoted.next);
}
});
void test('separate native payments and different donor payers never aggregate to a threshold', () => {
const ctx = context();
let state = createBankerIncomeState(ctx);
for (const event of ['first-two', 'second-two']) state = earn(state, ctx, source(ctx, event, 2));
const split = source(ctx, 'different-payers');
split.bankLegs = [{ payer: 'b', amount: 2 }, { payer: 'c', amount: 2 }];
state = earn(state, ctx, split);
assert.deepEqual(projectBankerIncome(state, ctx).deferred, []);
const ownAndDonor = source(ctx, 'own-plus-donor');
ownAndDonor.bankLegs = [{ payer: 'a', amount: 8 }, { payer: 'c', amount: 4 }];
state = earn(state, ctx, ownAndDonor);
assert.deepEqual(projectBankerIncome(state, ctx).deferred, [{ owner: 'a', amount: 1 }]);
const duplicate = source(ctx, 'duplicate-donor');
duplicate.bankLegs = [{ payer: 'b', amount: 2 }, { payer: 'b', amount: 2 }];
immutableReject(state, () => quoteBankerIncome(state, ctx, duplicate));
});
void test('physical card once-phase stamp survives death, absence, capture and same-phase reassignment', () => {
const ctx = context();
let state = earn(createBankerIncomeState(ctx), ctx, source(ctx));
const dead = context();
dead.players[0].leaders[0].dead = true;
dead.assignments = [];
const unavailable = { ...source(ctx, 'no-card'), trainer: null };
state = earn(state, dead, unavailable);
assert.deepEqual(projectBankerIncome(state, dead), { deferred: [{ owner: 'a', amount: 1 }], usedThisPhase: ['a'] });
const transferred = context(1, 3, 'b');
state = earn(state, transferred, source(transferred, 'reassigned-this-phase', 6, 'c'));
assert.equal(state.sources[2].grant, null);
assert.deepEqual(projectBankerIncome(state, transferred).usedThisPhase, ['a']);
const nextPhase = context(1, 4, 'b');
const revival = { ...source(nextPhase, 'next-phase', 4, 'c'), kind: 'leader-revival' as const };
state = earn(state, nextPhase, revival);
assert.deepEqual(projectBankerIncome(state, nextPhase), {
deferred: [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }], usedThisPhase: ['b'],
});
const nextTurn = context(2, 3, 'b');
state = earn(state, nextTurn, source(nextTurn, 'next-turn', 4, 'c'));
assert.deepEqual(projectBankerIncome(state, nextTurn).deferred, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 2 }]);
});
void test('fresh eligibility requires actual native living assignment and not a captive normal band', () => {
const ctx = context();
const state = createBankerIncomeState(ctx);
const dead = context();
dead.players[0].leaders[0].dead = true;
immutableReject(state, () => quoteBankerIncome(state, dead, source(ctx)));
const absent = context();
absent.assignments = [];
immutableReject(state, () => quoteBankerIncome(state, absent, source(ctx)));
const foreign = source(ctx);
foreign.trainer!.assignment.leader = 'guild-0';
immutableReject(state, () => quoteBankerIncome(state, ctx, foreign));
const captured = context();
captured.players[0].leaders[0].capturedBy = 'b';
const captive = source(captured);
captive.trainer!.captured = true;
const quoted = quoteBankerIncome(state, captured, captive);
assert.equal(quoted.receipt.grant, null);
captive.trainer!.captured = false;
immutableReject(state, () => quoteBankerIncome(state, captured, captive));
});
void test('concealed battle trainer needs actual revealed selection and resolved survival, not private plan timing', () => {
const ctx = context(1, 6);
for (const [faceUp, selected, resolved, survived, expected] of [
[true, false, true, true, 1],
[true, false, false, true, 0],
[true, false, true, false, 0],
[false, false, true, true, 0],
[false, true, false, true, 0],
[false, true, true, false, 0],
[false, true, true, true, 1],
] as const) {
const authority = { ...source(ctx, 'resolved-support'), kind: 'battle-support' as const };
Object.assign(authority.trainer!, { faceUp, selected, resolved, survived });
const quote = quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority);
assert.equal(quote.receipt.grant?.amount ?? 0, expected);
}
const impossible = { ...source(ctx), kind: 'battle-support' as const };
impossible.trainer!.selected = true;
assert.throws(() => validateBankerIncomeSource(ctx, impossible), BankerIncomeError);
});
void test('all named native producer kinds are phase-bound and empty bank settlement never triggers', () => {
const kinds = [
['auction', 3], ['shipment', 5], ['force-revival', 4], ['leader-revival', 4],
['kh-revival', 4], ['emperor-extra-revival', 4], ['battle-support', 6],
] as const;
for (const [kind, phase] of kinds) {
const ctx = context(1, phase);
const authority = { ...source(ctx, `native-${kind}`), kind };
assert.equal(quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority).receipt.grant?.amount, 1);
authority.bankLegs = [];
assert.equal(quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority).receipt.grant, null);
authority.phase = phase === 3 ? 4 : 3;
assert.throws(() => validateBankerIncomeSource(ctx, authority), BankerIncomeError);
}
});
void test('native Mentat atomically collects every original-owner grant once despite trainer/card changes', () => {
const ctx = context();
let state = earn(createBankerIncomeState(ctx), ctx, source(ctx));
const revivalContext = context(1, 4, 'b');
state = earn(state, revivalContext, { ...source(revivalContext, 'revival', 4, 'c'), kind: 'force-revival' });
const mentat = context(1, 8);
mentat.players[0].leaders[0].dead = true;
mentat.players[1].leaders[0].capturedBy = 'c';
mentat.assignments = [];
const before = JSON.stringify(state);
assert.deepEqual(projectBankerIncome(state, mentat).deferred, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }]);
const quoted = quoteBankerIncomeCollection(state, mentat, opening);
assert.equal(JSON.stringify(state), before);
assert.deepEqual(quoted.credits, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }]);
assert.deepEqual(quoted.collection.grants, [1, 2]);
state = commitBankerIncomeCollection(state, mentat, opening, quoted.collection);
assert.deepEqual(projectBankerIncome(state, mentat), { deferred: [], usedThisPhase: [] });
immutableReject(state, () => quoteBankerIncomeCollection(state, mentat, opening));
immutableReject(state, () => quoteBankerIncomeCollection(state, mentat, { ...opening, event: 'different-opening-id' }));
const later = context(2, 3, 'b');
later.players[0].leaders[0].capturedBy = 'b';
assert.deepEqual(projectBankerIncome(JSON.parse(JSON.stringify(state)) as BankerIncomeState, later), { deferred: [], usedThisPhase: [] });
state = earn(state, later, source(later, 'new-turn-payment', 4, 'c'));
const second = { event: 'native-mentat-2', turn: 2, phase: 8 as const };
const nextOpening = quoteBankerIncomeCollection(state, context(2, 8, 'b'), second);
assert.deepEqual(nextOpening.credits, [{ owner: 'b', amount: 1 }]);
});
void test('collection cannot happen early, change owner/amount, omit pending grants, or commit an old head', () => {
const ctx = context();
const state = earn(createBankerIncomeState(ctx), ctx, source(ctx));
immutableReject(state, () => quoteBankerIncomeCollection(state, ctx, opening));
const mentat = context(1, 8);
const quote = quoteBankerIncomeCollection(state, mentat, opening);
for (const corrupt of [
{ ...quote.collection, grants: [] },
{ ...quote.collection, credits: [{ owner: 'b', amount: 1 }] },
{ ...quote.collection, credits: [{ owner: 'a', amount: 2 }] },
{ ...quote.collection, event: 'foreign-opening' },
{ ...quote.collection, previous: '0000000000000000' },
]) immutableReject(state, () => commitBankerIncomeCollection(state, mentat, opening, corrupt));
const fresh = { ...source(context(1, 4), 'extra-revival'), kind: 'leader-revival' as const };
const advanced = earn(state, context(1, 4), fresh);
immutableReject(advanced, () => commitBankerIncomeCollection(advanced, mentat, opening, quote.collection));
});
void test('native authority is independent of stored receipt, and committed source cannot be replayed', () => {
const ctx = context();
const state = createBankerIncomeState(ctx);
const authority = source(ctx);
const quote = quoteBankerIncome(state, ctx, authority);
const changedAuthority = source(ctx, authority.event, 3);
immutableReject(state, () => commitBankerIncome(state, ctx, changedAuthority, quote.receipt));
const changedReceipt = structuredClone(quote.receipt);
changedReceipt.source.bankLegs[0].amount = 5;
immutableReject(state, () => commitBankerIncome(state, ctx, authority, changedReceipt));
const committed = commitBankerIncome(state, ctx, authority, quote.receipt);
immutableReject(committed, () => commitBankerIncome(committed, ctx, authority, quote.receipt));
immutableReject(committed, () => quoteBankerIncome(committed, context(2, 3), authority));
quote.receipt.source.bankLegs[0].amount = 90;
authority.bankLegs[0].amount = 100;
assert.equal(committed.sources[0].source.bankLegs[0].amount, 4);
});
void test('malformed payments, foreign payers, extra fields and unsupported sources reject without mutation', () => {
const ctx = context();
const state = createBankerIncomeState(ctx);
const valid = source(ctx);
const invalid: unknown[] = [null, {}, { ...valid, kind: 'gift' }, { ...valid, recipient: 'bank' },
{ ...valid, turn: 0 }, { ...valid, turn: 2 }, { ...valid, event: '' },
...[-1, 0, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1].map((amount) =>
({ ...valid, bankLegs: [{ payer: 'b', amount }] })),
{ ...valid, bankLegs: [{ payer: 'bank', amount: 4 }] },
{ ...valid, bankLegs: [{ payer: 'b', amount: 4, wallet: 99 }] },
{ ...valid, trainer: { ...valid.trainer, survived: 'yes' } },
];
for (const value of invalid) immutableReject(state, () => quoteBankerIncome(state, ctx, value as BankerIncomeAuthority));
});
void test('strict historical signatures, grant entitlement, cursor and collection custody reject corrupted JSON', () => {
const ctx = context();
const earned = earn(createBankerIncomeState(ctx), ctx, source(ctx));
const mentat = context(1, 8);
const collected = quoteBankerIncomeCollection(earned, mentat, opening).next;
const changedPayer = structuredClone(earned);
changedPayer.sources[0].source.bankLegs[0].payer = 'a';
const changedOwner = structuredClone(earned);
changedOwner.sources[0].grant!.owner = 'b';
const alteredSource = structuredClone(earned);
alteredSource.sources[0].source.bankLegs[0].amount = 3;
const duplicateSource = structuredClone(earned);
duplicateSource.sources.push(structuredClone(duplicateSource.sources[0]));
duplicateSource.cursor++;
const alteredCollection = structuredClone(collected);
alteredCollection.collections[0].credits[0].owner = 'b';
const missingCollection = structuredClone(collected);
missingCollection.collections[0].grants = [];
const invalid: unknown[] = [null, { ...earned, extra: true }, { ...earned, head: '0000000000000000' },
{ ...earned, signature: '0000000000000000' }, { ...earned, cursor: 0 },
{ ...earned, seats: ['a', 'b', 'foreign'] }, changedPayer, changedOwner, alteredSource,
duplicateSource, alteredCollection, missingCollection,
{ ...earned, sources: [{ ...earned.sources[0], grant: { ...earned.sources[0].grant, amount: 2 } }] },
{ ...earned, sources: [{ ...earned.sources[0], extra: true }] },
];
for (const value of invalid) assert.throws(() => validateBankerIncomeState(value as BankerIncomeState, mentat), BankerIncomeError);
});
void test('public projection exposes only shield custody and used owner, never exact hidden source/trainer/payment', () => {
const ctx = context();
const authority = source(ctx, 'private-native-event-id', 17, 'c');
const state = earn(createBankerIncomeState(ctx), ctx, authority);
const before = JSON.stringify(state);
const publicView = projectBankerIncome(state, ctx);
assert.deepEqual(publicView, { deferred: [{ owner: 'a', amount: 1 }], usedThisPhase: ['a'] });
publicView.deferred[0].amount = 90;
publicView.usedThisPhase[0] = 'b';
assert.equal(JSON.stringify(state), before);
const receipt: BankerIncomeReceipt = state.sources[0];
assert.equal(receipt.source.bankLegs[0].amount, 17);
assert.equal(receipt.grant!.owner, 'a');
});