Files
Dune/tests/spice-banker-income-runtime.test.ts
T

314 lines
21 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction,viewGame,type Game} from '../game/engine';
import {advanceSpiceBankerIncomeToMentat,awaitSpiceBankerIncomeRevivalActor,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)!;
if(fixture.game.phase===4) {
assert.ok(fixture.game.revivalOrder);
assert.equal(fixture.game.revivalOrder.remaining[0],kind==='emperor-extra-revival'?payerBefore.ally: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,revivalBeforeBanker:'harkonnen'});const paid=pay(fixture);
const lifecycle=createSpiceBankerIncomeReassignmentFixture(paid);
assert.equal(lifecycle.game.revivalOrder!.remaining[0],lifecycle.actor);
assert.ok(lifecycle.game.revivalOrder!.remaining.includes(fixture.owner));
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=awaitSpiceBankerIncomeRevivalActor(game,fixture.owner);
assert.equal(game.revivalOrder!.remaining[0],fixture.owner);
assert.ok(game.revivalOrder!.completed.includes(lifecycle.actor));
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);
});