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:
1 parent
13c46450a9
commit
6785e622a7
18 files changed
+2767
-13
No files matched your search
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user