Files
Dune/tests/richese-contribution-payments.test.ts

251 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
import type { FactionId } from '../game/catalog';
import {
initializeAdvancedNativeSkillsSetup, advancedNativePlayer, advancedNativeStep,
advanceAdvancedNativeSkillsToPhase, rejectAdvancedNativeAction,
} from './fixture-advanced-native-skills';
import { advanceSpiceBankerIncomeToMentat } from './fixture-spice-banker-income';
type Options = {
rules?: 'basic' | 'advanced'; source?: 'cache' | 'blackMarket';
emperor?: boolean; buyer?: 'atreides' | 'emperor' | 'richese';
banker?: boolean; counter?: boolean;
};
type NativeRichesePaymentFixture = {
game: Game; seller: string; buyer: string; observer: string; counter?: string; card: string;
};
function choose(game: Game, values: Record<string, unknown>): Game {
return applyAction(game, game.decision!.player, {
type: 'decision', event: game.richeseBidding!.event, ...values,
} as Action);
}
function allowOriginalResponses(state: Game): Game {
let game = state;
for (let i = 0; game.response && i < 100; i++) {
const actor = game.players.find(p => p.id !== game.response!.owner && !game.response!.passed.includes(p.id));
assert.ok(actor, 'The original counter window must retain an unpassed participant.');
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
function prepare(options: Options = {}): NativeRichesePaymentFixture {
const opponents: FactionId[] = ['atreides', 'beneGesserit'];
if (options.emperor !== false) opponents.push('emperor');
let game = initializeAdvancedNativeSkillsSetup({
family: 'richese', rules: options.rules ?? 'advanced', opponents,
requestedSkill: options.banker ? 'spice-banker' : 'suk-graduate',
skillOwner: options.banker ? 'beneGesserit' : 'richese',
});
const skillOwner = advancedNativePlayer(game, options.banker ? 'beneGesserit' : 'richese').id;
const requested = options.banker ? 'spice-banker' : 'suk-graduate';
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = actor === skillOwner ? requested :
offer.cards.find(card => card !== 'bureaucrat' && !own.unavailableSkills?.[card]);
assert.ok(skill && offer.cards.includes(skill));
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id });
} else game = advancedNativeStep(game);
} // Choose genuine alternate offers: unresolved Bureaucrat split funding is not this rule.
game = advanceAdvancedNativeSkillsToPhase(game, 2);
const seller = advancedNativePlayer(game, 'richese').id;
const buyer = advancedNativePlayer(game, options.buyer ?? 'atreides').id;
const observer = advancedNativePlayer(game, 'beneGesserit').id;
// Explicit controlled alliance position after original all14/native setup;
// wallets, force inventories, skills and original starting hands stay intact.
if (buyer !== seller) {
game.players.find(p => p.id === seller)!.ally = buyer;
game.players.find(p => p.id === buyer)!.ally = seller;
}
let counter: string | undefined;
if (options.counter) {
const owner = game.players.find(p => p.id === observer)!;
let card = owner.hand.find(c => c.effect === 'karama');
if (!card) {
const index = game.deck.findIndex(c => c.effect === 'karama');
if (index >= 0) card = game.deck.splice(index, 1)[0];
else {
const holder = game.players.find(p => p.id !== observer && p.hand.some(c => c.effect === 'karama'))!;
assert.ok(holder, 'Transfer an existing original physical Karama, never manufacture one.');
const returned = owner.hand.pop();
assert.ok(returned, 'Exchange an original held card so donor auction eligibility is preserved.');
card = holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1, returned)[0];
}
owner.hand.push(card);
}
counter = card.id;
}
for (let i = 0; game.phase === 2 && i < 100; i++) game = advancedNativeStep(game);
assert.equal(game.phase, 3);
if (game.advanced) {
assert.equal(game.decision?.kind, 'richeseBlackMarket');
if (options.source === 'blackMarket') {
const card = game.players.find(p => p.id === seller)!.hand[0];
assert.ok(card);
game = choose(game, { card: card.id, method: 'silent' });
} else game = choose(game, { decline: true });
}
if (options.source !== 'blackMarket') {
assert.equal(game.decision?.kind, 'richeseDeclaration');
game = choose(game, { position: 'first' });
game = allowOriginalResponses(game);
game = choose(game, { card: game.richeseCache![0].id, method: 'silent' });
}
// Pass only the original pre-sale counter windows. Never substitute a receipt.
game = allowOriginalResponses(game);
assert.ok(game.richeseAuction && !game.richeseAuction.outcome);
return { game, seller, buyer, observer, counter, card: game.richeseAuction.cardId };
}
function bid(game: Game, actor: string, amount: number, allyPayment = 0): Game {
return applyAction(game, actor, { type: 'richeseBid', event: game.richeseAuction!.event, amount, allyPayment });
}
function purchase(fixture: NativeRichesePaymentFixture, donation = 4, price = 8): Game {
let game = fixture.game;
if (donation) game = applyAction(game, fixture.seller, { type: 'pledgeAid', amount: donation });
game = bid(game, fixture.buyer, price, donation);
if (donation) {
rejectAdvancedNativeAction(game, fixture.seller, { type: 'pledgeAid', amount: donation - 1 }, /Ally funding/);
rejectAdvancedNativeAction(game, fixture.buyer, {
type: 'richeseBid', event: game.richeseAuction!.event, amount: 1,
}, /already submitted/);
}
for (const actor of game.richeseAuction!.order)
if (actor !== fixture.buyer) game = bid(game, actor, 0);
return game;
}
function finishResponses(state: Game): Game {
let game = state;
for (let i = 0; (game.response || (game.decision && !['richeseDeclaration', 'richeseCache'].includes(game.decision.kind))) && i < 200; i++)
game = advancedNativeStep(game);
assert.equal(game.response, null);
return game;
}
function balance(game: Game, actor: string): number { return game.players.find(p => p.id === actor)!.spice; }
function exactCard(game: Game, buyer: string, id: string): void {
assert.equal(game.players.find(p => p.id === buyer)!.hand.filter(c => c.id === id).length, 1);
const physical = [
...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...(game.ixAuction?.cards ?? []),
];
assert.equal(physical.filter(c => c.id === id).length, 1);
}
for (const source of ['cache', 'blackMarket'] as const) {
for (const mode of ['allow', 'cancel', 'emperorBuyer'] as const) {
void test(`Advanced native ${source}: Richese 4 plus buyer 4, Emperor ${mode}, settles card/escrow once`, () => {
const f = prepare({ source, counter: true, buyer: mode === 'emperorBuyer' ? 'emperor' : 'atreides' });
const before = f.game;
const emperor = advancedNativePlayer(before, 'emperor').id;
let game = purchase(f);
// Seller receives ONLY the non-Richese leg; donation is not refunded.
assert.equal(balance(game, f.seller), balance(before, f.seller));
assert.equal(balance(game, f.buyer), balance(before, f.buyer) - 4);
assert.equal(balance(game, emperor), balance(before, emperor) - (f.buyer === emperor ? 4 : 0));
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
exactCard(game, f.buyer, f.card);
assert.equal(game.response?.kind, 'emperorIncome');
assert.equal(game.currentAuctionSale?.richeseContribution, 4);
if (mode === 'cancel') game = applyAction(game, f.observer, { type: 'card', card: f.counter!, mode: 'cancel' });
game = finishResponses(game);
assert.equal(balance(game, f.seller), balance(before, f.seller));
assert.equal(balance(game, f.buyer), balance(before, f.buyer) - 4 + (mode !== 'cancel' && f.buyer === emperor ? 4 : 0));
assert.equal(balance(game, emperor), balance(before, emperor) - (f.buyer === emperor ? 4 : 0) + (mode === 'cancel' ? 0 : 4));
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
exactCard(game, f.buyer, f.card);
if (mode === 'cancel') assert.equal(game.discard.filter(c => c.id === f.counter).length, 1);
if (source === 'blackMarket') {
assert.equal(game.decision?.kind, 'richeseDeclaration');
assert.equal(game.richeseBidding?.blackMarketSold, true);
} else assert.ok(game.auction);
});
}
}
void test('Basic native cache keeps its original publisher seller receipt, not an inferred Advanced contributor rule', () => {
const f = prepare({ rules: 'basic', counter: true });
let game = purchase(f);
game = finishResponses(game);
assert.equal(balance(game, f.seller), balance(f.game, f.seller) + 4);
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
const emperor = advancedNativePlayer(game, 'emperor').id;
assert.equal(balance(game, emperor), balance(f.game, emperor));
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
exactCard(game, f.buyer, f.card);
});
for (const rules of ['basic', 'advanced'] as const) {
void test(`${rules} native Richese self-cache pays bank once, with no seller refund`, () => {
const f = prepare({ rules, emperor: false, buyer: 'richese' });
const game = finishResponses(purchase(f, 0, 4));
assert.equal(balance(game, f.seller), balance(f.game, f.seller) - 4);
exactCard(game, f.buyer, f.card);
});
}
for (const source of ['cache', 'blackMarket'] as const) {
void test(`Advanced native ${source}: only Richese bank-routed contribution qualifies Banker, deferred until native Mentat`, () => {
const f = prepare({ source, emperor: false, banker: true });
const ownerBalance = balance(f.game, f.observer);
let game = finishResponses(purchase(f));
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
assert.equal(balance(game, f.observer), ownerBalance);
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, [{ owner: f.observer, amount: 1 }]);
const grants = game.spiceBankerIncome!.sources.filter(row => row.grant);
assert.equal(grants.length, 1);
assert.deepEqual(grants[0].source.bankLegs, [{ payer: f.seller, amount: 4 }]);
exactCard(game, f.buyer, f.card);
// Native phase suffix, retaining cache-first when a Black Market sale ends.
game = advanceAdvancedNativeSkillsToPhase(game, 7);
const beforeMentat = balance(game, f.observer);
rejectAdvancedNativeAction(game, f.observer, { type: 'bribe', target: f.buyer, amount: beforeMentat + 1 });
game = advanceSpiceBankerIncomeToMentat(game);
assert.equal(balance(game, f.observer), beforeMentat + 1);
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
exactCard(game, f.buyer, f.card);
});
}
void test('Advanced Emperor contribution cancellation qualifies Banker for donor leg only, not the unrelated buyer leg', () => {
const f = prepare({ banker: true, counter: true });
let game = purchase(f);
assert.equal(game.response?.kind, 'emperorIncome');
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
game = applyAction(game, f.observer, { type: 'card', card: f.counter!, mode: 'cancel' });
game = finishResponses(game);
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, [{ owner: f.observer, amount: 1 }]);
assert.deepEqual(game.spiceBankerIncome!.sources.find(row => row.grant)!.source.bankLegs,
[{ payer: f.seller, amount: 4 }]);
exactCard(game, f.buyer, f.card);
});
void test('Advanced native bank contribution below four never combines unrelated buyer-to-seller payment for Banker', () => {
const f = prepare({ banker: true, emperor: false });
const game = finishResponses(purchase(f, 3, 8));
assert.equal(balance(game, f.seller), balance(f.game, f.seller) + 2);
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 5);
assert.equal(balance(game, f.observer), balance(f.game, f.observer));
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
assert.equal(game.spiceBankerIncome!.sources.some(row => row.grant), false);
exactCard(game, f.buyer, f.card);
});
void test('Advanced native allowed Emperor contribution is faction income, never Banker bank income', () => {
const f = prepare({ banker: true, counter: true });
const game = finishResponses(purchase(f));
const emperor = advancedNativePlayer(game, 'emperor').id;
assert.equal(balance(game, emperor), balance(f.game, emperor) + 4);
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
assert.equal(game.spiceBankerIncome!.sources.some(row => row.grant), false);
exactCard(game, f.buyer, f.card);
});