fix: bind Richese Bureaucrat to actual recipient portions
This commit is contained in:
1 parent
06342ad34c
commit
7891517a9b
12 files changed
+711
-31
No files matched your search
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
allowRicheseBureaucratResponses,
|
||||
chooseRicheseBureaucrat,
|
||||
createRicheseBureaucratFixture,
|
||||
payRicheseBureaucrat,
|
||||
richeseBureaucratCards,
|
||||
richeseBureaucratPlayer,
|
||||
type RicheseBureaucratFixture,
|
||||
} from './fixture-bureaucrat-richese-funding';
|
||||
|
||||
function allocation(game: Game, f: RicheseBureaucratFixture, payee: string) {
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(game.decision!.player, f.trainer);
|
||||
const event = game.bureaucratPaymentEvent;
|
||||
assert.ok(event);
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
assert.deepEqual(view.bureaucrat.pending, {
|
||||
event,
|
||||
owner: f.trainer,
|
||||
payer: null,
|
||||
payee,
|
||||
amount: f.price,
|
||||
kind: 'auction',
|
||||
redirect: 2,
|
||||
richeseAllocation: true,
|
||||
}, 'Every seat sees the original public price, never the private recipient leg or qualifying payer');
|
||||
assert.equal('bureaucratPayments' in view, false);
|
||||
assert.equal('bureaucratUseEvents' in view, false);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
function paidOnce(game: Game, f: RicheseBureaucratFixture, sellerReceipt: number, emperorReceipt: number) {
|
||||
const balances = new Map(f.beforePledge.players.map(player => [player.id, player.spice]));
|
||||
balances.set(f.donor, balances.get(f.donor)! - f.pledge + sellerReceipt);
|
||||
balances.set(f.payer, balances.get(f.payer)! - f.primary);
|
||||
if (f.emperor) balances.set(f.emperor, balances.get(f.emperor)! + emperorReceipt);
|
||||
else assert.equal(emperorReceipt, 0);
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.spice, balances.get(player.id), `${player.id} retains only its actual payment and receipt`);
|
||||
const before = richeseBureaucratPlayer(f.beforeAction, player.id);
|
||||
const delta = Number(player.id === f.payer) - Number(f.source === 'blackMarket' && player.id === f.seller);
|
||||
assert.equal(player.hand.length, before.hand.length + delta);
|
||||
}
|
||||
assert.equal(f.beforeAction.aid[f.donor].amount, f.pledge);
|
||||
assert.equal(game.aid[f.donor].amount, 0, 'Original prepaid donor escrow is consumed once');
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(richeseBureaucratCards(game), f.physical);
|
||||
assert.equal(game.currentAuctionSale, null);
|
||||
assert.equal(game.richeseAuction, null);
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.pending, null);
|
||||
}
|
||||
|
||||
function blackMarketDeclaration(game: Game, f: RicheseBureaucratFixture) {
|
||||
assert.equal(game.richeseBidding!.blackMarketSold, true);
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration');
|
||||
assert.equal(game.decision!.player, f.seller);
|
||||
assert.equal(game.active, f.seller);
|
||||
assert.deepEqual(game.richeseCache, f.beforeAction.richeseCache, 'A hidden hand sale does not spend a cache card');
|
||||
}
|
||||
|
||||
void test('Advanced Black Market buyer5/donor3 diverts only seller5, retains native Emperor3 and resumes the cache declaration', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', primary: 5, pledge: 3, counter: true });
|
||||
assert.equal(viewGame(f.game, f.trainer).richeseAuction!.card, null);
|
||||
assert.equal(richeseBureaucratPlayer(f.game, f.seller).hand.filter(card => card.id === f.card).length, 1);
|
||||
let game = payRicheseBureaucrat(f);
|
||||
const event = allocation(game, f, f.seller);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).spice, richeseBureaucratPlayer(f.beforeAction, f.payer).spice - 5);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).spice, richeseBureaucratPlayer(f.beforeAction, f.seller).spice);
|
||||
game = chooseRicheseBureaucrat(game, true);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).spice, richeseBureaucratPlayer(f.beforeAction, f.seller).spice + 3);
|
||||
assert.equal(game.response?.kind, 'emperorIncome', 'Using Bureaucrat on seller income does not suppress native Karama');
|
||||
assert.equal(richeseBureaucratPlayer(game, f.emperor!).spice, richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice);
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
paidOnce(game, f, 3, 3);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
assert.equal(game.bureaucratPayments!.used.filter(use => use.turn === game.turn && use.phase === game.phase).length, 1);
|
||||
const beforeStale = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, f.trainer, { type: 'decision', event, redirect: true }));
|
||||
assert.equal(JSON.stringify(game), beforeStale);
|
||||
|
||||
game = applyAction(game, f.seller, { type: 'decision', event: game.richeseBidding!.event, position: 'first' });
|
||||
assert.equal(game.response?.kind, 'richeseAuction');
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
assert.equal(game.decision?.kind, 'richeseCache');
|
||||
assert.equal(game.decision!.player, f.seller);
|
||||
assert.equal(game.richeseBidding!.blackMarketSold, true);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(richeseBureaucratCards(game), f.physical);
|
||||
});
|
||||
|
||||
void test('an actual Emperor buyer3 still receives Richese donor5, with third-party Bureaucrat diverting only that receipt', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', buyer: 'emperor' });
|
||||
let game = allowRicheseBureaucratResponses(payRicheseBureaucrat(f));
|
||||
allocation(game, f, f.emperor!);
|
||||
assert.equal(game.bureaucratPayments!.pending!.resume.response?.kind, 'emperorIncome');
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).spice, richeseBureaucratPlayer(f.beforeAction, f.seller).spice + 3);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).spice, richeseBureaucratPlayer(f.beforeAction, f.payer).spice - 3);
|
||||
game = chooseRicheseBureaucrat(game, true);
|
||||
paidOnce(game, f, 3, 3);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
});
|
||||
|
||||
void test('the actual Atreides Bureaucrat skips its own buyer5 leg but can divert Richese donor5 to Emperor', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', owner: 'atreides', primary: 5 });
|
||||
let game = allowRicheseBureaucratResponses(payRicheseBureaucrat(f));
|
||||
allocation(game, f, f.emperor!);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).spice, richeseBureaucratPlayer(f.beforeAction, f.seller).spice + 5);
|
||||
game = chooseRicheseBureaucrat(game, true);
|
||||
paidOnce(game, f, 5, 3);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
});
|
||||
|
||||
void test('the actual Richese Bureaucrat cannot divert either its seller5 receipt or its own donor5 payment', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', owner: 'richese', primary: 5 });
|
||||
const game = allowRicheseBureaucratResponses(payRicheseBureaucrat(f));
|
||||
paidOnce(game, f, 5, 5);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
});
|
||||
|
||||
void test('the actual Emperor Bureaucrat may decline buyer5 to Richese but cannot divert its own incoming donor5', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', owner: 'emperor', primary: 5 });
|
||||
let game = payRicheseBureaucrat(f);
|
||||
allocation(game, f, f.seller);
|
||||
game = chooseRicheseBureaucrat(game, false);
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
paidOnce(game, f, 5, 5);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, false, 'Declining the first offer leaves the skill unused, so the payee exclusion alone suppresses the second');
|
||||
});
|
||||
|
||||
void test('Basic cache control keeps the whole seller8 receipt, reduced to6, without an Advanced donor reroute', () => {
|
||||
const f = createRicheseBureaucratFixture({ advanced: false, primary: 5, pledge: 3 });
|
||||
let game = payRicheseBureaucrat(f);
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.pending!.payee, f.seller);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.pending!.amount, 8);
|
||||
game = chooseRicheseBureaucrat(game, true);
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
paidOnce(game, f, 6, 0);
|
||||
assert.equal(game.richeseCache!.length, f.beforeAction.richeseCache!.length - 1);
|
||||
assert.equal(game.richeseCache!.some(card => card.id === f.card), false);
|
||||
assert.equal(game.richeseBidding!.stage, 'normal');
|
||||
assert.ok(game.auction, 'The original first-cache continuation prepares normal lots');
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
});
|
||||
|
||||
void test('without Emperor the actual Black Market donor5 goes to Bank, buyer3 stays below Bureaucrat threshold, and Guild Banker defers1', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', emperor: false });
|
||||
const bankerBefore = richeseBureaucratPlayer(f.beforeAction, f.banker).spice;
|
||||
const sourcesBefore = f.beforeAction.spiceBankerIncome!.sources.length;
|
||||
const game = allowRicheseBureaucratResponses(payRicheseBureaucrat(f));
|
||||
paidOnce(game, f, 3, 0);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.banker).spice, bankerBefore, 'Earned Banker income is not immediate spendable spice');
|
||||
assert.deepEqual(viewGame(game, f.banker).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
|
||||
const receipts = game.spiceBankerIncome!.sources.slice(sourcesBefore);
|
||||
assert.equal(receipts.filter(receipt => receipt.grant).length, 1);
|
||||
assert.deepEqual(receipts.flatMap(receipt => receipt.source.bankLegs), [{ payer: f.donor, amount: 5 }], 'Only the actual donor Bank leg earns income; buyer3 is paid to Richese, not pooled into a fictitious Bank or player receipt');
|
||||
assert.equal(receipts.find(receipt => receipt.grant)!.grant!.owner, f.banker);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createRicheseBureaucratFixture, payRicheseBureaucrat,
|
||||
allowRicheseBureaucratResponses, chooseRicheseBureaucrat,
|
||||
richeseBureaucratPlayer, richeseBureaucratCards,
|
||||
type RicheseBureaucratFixture,
|
||||
} from './fixture-bureaucrat-richese-funding';
|
||||
import {
|
||||
advanceSpiceBankerIncomeToMentat, nextSpiceBankerIncomeNativeStep,
|
||||
} from './fixture-spice-banker-income';
|
||||
|
||||
const spice = (game: Game, actor: string) => richeseBureaucratPlayer(game, actor).spice;
|
||||
|
||||
function rejectUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
}
|
||||
|
||||
function assertPaidCard(f: RicheseBureaucratFixture, game: Game): void {
|
||||
assert.equal(spice(game, f.payer), spice(f.beforePledge, f.payer) - f.primary);
|
||||
assert.equal(game.aid[f.donor]?.amount ?? 0, 0);
|
||||
assert.deepEqual(game.richeseFunding, {});
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).hand.filter(c => c.id === f.card).length, 1);
|
||||
assert.deepEqual(richeseBureaucratPlayer(game, f.payer).hand.map(c => c.id),
|
||||
[...richeseBureaucratPlayer(f.beforeAction, f.payer).hand.map(c => c.id), f.card]);
|
||||
assert.equal(game.richeseCache!.some(c => c.id === f.card), false);
|
||||
assert.deepEqual(richeseBureaucratCards(game), f.physical);
|
||||
}
|
||||
|
||||
function assertNormalContinuation(f: RicheseBureaucratFixture, game: Game): void {
|
||||
assertPaidCard(f, game);
|
||||
assert.equal(game.phase, 3);
|
||||
assert.equal(game.turn, f.beforeAction.turn);
|
||||
assert.equal(game.currentAuctionSale, null);
|
||||
assert.equal(game.richeseAuction, null);
|
||||
assert.equal(game.richeseBidding!.stage, 'normal');
|
||||
assert.ok(game.auction);
|
||||
assert.equal(game.auction.index, 0);
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(game.response, null);
|
||||
}
|
||||
|
||||
function assertPublicAllocation(f: RicheseBureaucratFixture, game: Game, payee: string): void {
|
||||
const event = game.bureaucratPaymentEvent;
|
||||
assert.ok(event);
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
assert.deepEqual(view.bureaucrat.pending, {
|
||||
event, owner: f.trainer, payer: null, payee, amount: f.price,
|
||||
kind: 'auction', redirect: 2, richeseAllocation: true,
|
||||
});
|
||||
const publicState = view as unknown as Record<string, unknown>;
|
||||
assert.equal('richeseFunding' in publicState, false);
|
||||
assert.equal('bureaucratPayments' in publicState, false);
|
||||
assert.equal('bureaucratPayment' in ((publicState.currentAuctionSale as Record<string, unknown> | undefined) ?? {}), false);
|
||||
assert.equal('bureaucratPayment' in (view.response ?? {}), false);
|
||||
}
|
||||
}
|
||||
|
||||
void test('Advanced native cache 3+5: only Richese-to-Emperor five qualifies, redirects two and resumes ordinary lots once', () => {
|
||||
const f = createRicheseBureaucratFixture({ primary: 3, pledge: 5, counter: true });
|
||||
const paid = payRicheseBureaucrat(f);
|
||||
assertPaidCard(f, paid);
|
||||
assert.equal(paid.response?.kind, 'emperorIncome');
|
||||
assert.equal(viewGame(paid, f.trainer).bureaucrat.pending, null);
|
||||
assert.equal(spice(paid, f.donor), spice(f.beforePledge, f.donor) - 5 + 3);
|
||||
assert.equal(spice(paid, f.emperor!), spice(f.beforePledge, f.emperor!));
|
||||
|
||||
const pending = allowRicheseBureaucratResponses(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.bureaucratPayments!.pending!.source.payer, f.donor);
|
||||
assert.equal(pending.bureaucratPayments!.pending!.source.payee, f.emperor);
|
||||
assert.equal(pending.bureaucratPayments!.pending!.source.amount, 5);
|
||||
assertPublicAllocation(f, pending, f.emperor!);
|
||||
assert.equal(spice(pending, f.emperor!), spice(f.beforePledge, f.emperor!));
|
||||
|
||||
const done = allowRicheseBureaucratResponses(chooseRicheseBureaucrat(pending, true));
|
||||
assertNormalContinuation(f, done);
|
||||
assert.equal(spice(done, f.donor), spice(f.beforePledge, f.donor) - 2);
|
||||
assert.equal(spice(done, f.emperor!), spice(f.beforePledge, f.emperor!) + 3);
|
||||
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null);
|
||||
rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
|
||||
// Exercise the original ordinary opener, not merely the special-lot callback.
|
||||
const balances = done.players.map(p => [p.id, p.spice]);
|
||||
const hands = done.players.map(p => [p.id, p.hand.map(c => c.id)]);
|
||||
const used = structuredClone(done.bureaucratPayments!.used);
|
||||
const actor = done.auction!.active;
|
||||
const next = allowRicheseBureaucratResponses(applyAction(done, actor, { type: 'passBid' }));
|
||||
assert.equal(next.auction!.index, 0);
|
||||
assert.notEqual(next.auction!.active, actor);
|
||||
assert.deepEqual(next.auction!.cards, done.auction!.cards);
|
||||
assert.deepEqual(next.players.map(p => [p.id, p.spice]), balances);
|
||||
assert.deepEqual(next.players.map(p => [p.id, p.hand.map(c => c.id)]), hands);
|
||||
assert.deepEqual(next.bureaucratPayments!.used, used);
|
||||
assert.deepEqual(richeseBureaucratCards(next), f.physical);
|
||||
});
|
||||
|
||||
void test('Advanced native cache 5+5: decline seller, allow Emperor, reject stale seller event and redirect only donor income', () => {
|
||||
const f = createRicheseBureaucratFixture({ primary: 5, pledge: 5, counter: true });
|
||||
const seller = payRicheseBureaucrat(f);
|
||||
assert.equal(seller.decision?.kind, 'bureaucratPayment');
|
||||
assertPublicAllocation(f, seller, f.seller);
|
||||
assertPaidCard(f, seller);
|
||||
assert.equal(spice(seller, f.seller), spice(f.beforePledge, f.seller) - 5);
|
||||
const sellerEvent = seller.bureaucratPaymentEvent;
|
||||
|
||||
const income = chooseRicheseBureaucrat(seller, false);
|
||||
assert.equal(income.response?.kind, 'emperorIncome');
|
||||
assert.equal(spice(income, f.seller), spice(f.beforePledge, f.seller));
|
||||
assert.equal(income.bureaucratPayments?.used.length ?? 0, 0);
|
||||
const donor = allowRicheseBureaucratResponses(income);
|
||||
assert.equal(donor.decision?.kind, 'bureaucratPayment');
|
||||
assert.notEqual(donor.bureaucratPaymentEvent, sellerEvent);
|
||||
assertPublicAllocation(f, donor, f.emperor!);
|
||||
assert.equal(donor.bureaucratPayments!.pending!.source.amount, 5);
|
||||
rejectUnchanged(donor, f.trainer, { type: 'decision', event: sellerEvent, redirect: true });
|
||||
rejectUnchanged(donor, f.donor, { type: 'decision', event: donor.bureaucratPaymentEvent, redirect: true });
|
||||
|
||||
const done = allowRicheseBureaucratResponses(chooseRicheseBureaucrat(donor, true));
|
||||
assertNormalContinuation(f, done);
|
||||
assert.equal(spice(done, f.seller), spice(f.beforePledge, f.seller));
|
||||
assert.equal(spice(done, f.emperor!), spice(f.beforePledge, f.emperor!) + 3);
|
||||
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||
assert.equal(done.bureaucratPayments!.used[0].event, donor.bureaucratPaymentEvent);
|
||||
});
|
||||
|
||||
void test('Advanced native cache 5+5: seller diversion does not cancel native Emperor Karama or pool buyer Bank two with donor five', () => {
|
||||
const f = createRicheseBureaucratFixture({ primary: 5, pledge: 5, counter: true });
|
||||
const seller = payRicheseBureaucrat(f);
|
||||
const sourceCount = f.beforeAction.spiceBankerIncome!.sources.length;
|
||||
const bankerBalance = spice(f.beforeAction, f.banker);
|
||||
const income = chooseRicheseBureaucrat(seller, true);
|
||||
assert.equal(income.response?.kind, 'emperorIncome');
|
||||
assert.equal(spice(income, f.seller), spice(f.beforePledge, f.seller) - 2);
|
||||
assert.equal(spice(income, f.emperor!), bankerBalance);
|
||||
assert.equal(viewGame(income, f.trainer).bureaucrat.pending, null);
|
||||
assert.equal(viewGame(income, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
assert.deepEqual(viewGame(income, f.banker).spiceBankerIncome!.deferred, []);
|
||||
assert.equal(income.spiceBankerIncome!.sources.slice(sourceCount).some(row => row.grant), false);
|
||||
assert.ok(viewGame(income, f.counterActor).responseControls!.cancelCards.includes(f.counterCard!));
|
||||
|
||||
const canceled = applyAction(income, f.counterActor, { type: 'card', card: f.counterCard!, mode: 'cancel' });
|
||||
const done = allowRicheseBureaucratResponses(canceled);
|
||||
assertNormalContinuation(f, done);
|
||||
assert.equal(spice(done, f.seller), spice(f.beforePledge, f.seller) - 2);
|
||||
assert.equal(spice(done, f.emperor!), bankerBalance);
|
||||
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||
assert.equal(done.bureaucratPayments!.used[0].event, seller.bureaucratPaymentEvent);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null);
|
||||
assert.equal(done.discard.filter(c => c.id === f.counterCard).length, 1);
|
||||
assert.equal(richeseBureaucratPlayer(done, f.counterActor).hand.some(c => c.id === f.counterCard), false);
|
||||
const grants = done.spiceBankerIncome!.sources.slice(sourceCount).filter(row => row.grant);
|
||||
assert.equal(grants.length, 1);
|
||||
assert.deepEqual(grants[0].source.bankLegs, [{ payer: f.donor, amount: 5 }]);
|
||||
assert.deepEqual(viewGame(done, f.banker).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
|
||||
rejectUnchanged(done, f.counterActor, { type: 'card', card: f.counterCard!, mode: 'cancel' });
|
||||
|
||||
// Native passes and endMovement leave the deferred credit untouched until Mentat.
|
||||
let beforeMentat = done;
|
||||
for (let n = 0; (beforeMentat.phase !== 7 || beforeMentat.phaseOpening || beforeMentat.response || beforeMentat.decision) && n < 400; n++) {
|
||||
const step = nextSpiceBankerIncomeNativeStep(beforeMentat);
|
||||
assert.ok(step);
|
||||
beforeMentat = applyAction(beforeMentat, step.actor, step.action);
|
||||
}
|
||||
assert.equal(beforeMentat.phase, 7);
|
||||
assert.equal(spice(beforeMentat, f.banker), bankerBalance);
|
||||
assert.deepEqual(viewGame(beforeMentat, f.banker).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
|
||||
rejectUnchanged(beforeMentat, f.banker, { type: 'bribe', target: f.trainer, amount: bankerBalance + 1 });
|
||||
const collected = advanceSpiceBankerIncomeToMentat(beforeMentat);
|
||||
assert.equal(spice(collected, f.banker), bankerBalance + 1);
|
||||
assert.deepEqual(viewGame(collected, f.banker).spiceBankerIncome!.deferred, []);
|
||||
const collection = collected.spiceBankerIncome!.collections.at(-1)!;
|
||||
assert.deepEqual(collection.credits, [{ owner: f.banker, amount: 1 }]);
|
||||
assert.equal(collection.grants.filter(cursor => cursor === grants[0].cursor).length, 1);
|
||||
assert.deepEqual(richeseBureaucratCards(collected), f.physical);
|
||||
});
|
||||
|
||||
void test('Advanced native cache 4+4: separate seller and Emperor receipts never pool into a Bureaucrat payer', () => {
|
||||
const f = createRicheseBureaucratFixture({ primary: 4, pledge: 4, counter: true });
|
||||
const income = payRicheseBureaucrat(f);
|
||||
assert.equal(income.response?.kind, 'emperorIncome');
|
||||
assertPaidCard(f, income);
|
||||
assert.equal(spice(income, f.seller), spice(f.beforePledge, f.seller));
|
||||
assert.equal(viewGame(income, f.trainer).bureaucrat.pending, null);
|
||||
const done = allowRicheseBureaucratResponses(income);
|
||||
assertNormalContinuation(f, done);
|
||||
assert.equal(spice(done, f.seller), spice(f.beforePledge, f.seller));
|
||||
assert.equal(spice(done, f.emperor!), spice(f.beforePledge, f.emperor!) + 4);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
assert.deepEqual(viewGame(done, f.banker).spiceBankerIncome!.deferred, []);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
|
||||
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
|
||||
|
||||
export type RicheseBureaucratOptions = {
|
||||
source?: 'cache' | 'blackMarket';
|
||||
advanced?: boolean;
|
||||
primary?: number;
|
||||
pledge?: number;
|
||||
buyer?: 'atreides' | 'emperor';
|
||||
owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
|
||||
emperor?: boolean;
|
||||
counter?: boolean;
|
||||
};
|
||||
export type RicheseBureaucratStep = { actor: string; action: Action };
|
||||
export function richeseBureaucratPlayer(game: Game, id: string) {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player, `Missing original seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
export function richeseBureaucratCards(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
|
||||
].map(c => c.id).sort();
|
||||
}
|
||||
export function allowRicheseBureaucratResponses(state: Game): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.response && n < 40; n++) {
|
||||
const actor = game.players.find(p => {
|
||||
const controls = viewGame(game, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
||||
});
|
||||
assert.ok(actor, 'An original unpassed counter holder must remain');
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Genuine native all14 setup, ordinary first-turn play, a real one-spice bribe
|
||||
* collected only at Mentat, original turn-two worm alliance and native special
|
||||
* lot. Only unused Spice ordering and a conserved counter-card swap are staged. */
|
||||
export function createRicheseBureaucratFixture(options: RicheseBureaucratOptions = {}) {
|
||||
const advanced = options.advanced ?? true, source = options.source ?? 'cache';
|
||||
assert.ok(advanced || source === 'cache');
|
||||
const payer = options.buyer ?? 'atreides', trainer = options.owner ?? 'beneGesserit';
|
||||
const donor = 'richese', emperor = options.emperor === false ? undefined : 'emperor';
|
||||
const roster: FactionId[] = ['richese', 'atreides', 'beneGesserit', emperor ?? 'guild'];
|
||||
assert.ok(roster.includes(payer) && roster.includes(trainer));
|
||||
const training: Partial<Record<FactionId, LeaderSkillId>> = {
|
||||
richese: 'smuggler', atreides: 'planetologist', beneGesserit: 'warmaster',
|
||||
[emperor ?? 'guild']: 'spice-banker', [trainer]: 'bureaucrat',
|
||||
};
|
||||
if (trainer === 'emperor') training.beneGesserit = 'spice-banker';
|
||||
const setup = createIndependentCardModulesFixture({ advanced, factions: roster, expansions: ['choam'],
|
||||
skills: true, independent: false, tech: false, training });
|
||||
let game = setup.game;
|
||||
const afterSetup = structuredClone(game), actions: RicheseBureaucratStep[] = [];
|
||||
const act = (actor: string, action: Action) => { actions.push({ actor, action }); game = applyAction(game, actor, action); };
|
||||
const next = () => {
|
||||
if (game.phase === 5 && homeworldEcazClean(game)) act(game.active!, { type: 'endMovement' });
|
||||
else {
|
||||
const step = nextHomeworldEcazStep(game);
|
||||
assert.ok(step, `Missing original Richese control ${game.phase}/${game.decision?.kind}`);
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
};
|
||||
const reach = (until: (g: Game) => boolean) => {
|
||||
for (let n = 0; !until(game) && n < 800; n++) next();
|
||||
assert.ok(until(game), 'Reach the original Richese funding boundary');
|
||||
};
|
||||
// The first native cache acquisition costs Richese one. This real deferred
|
||||
// transfer, not a wallet assignment, makes a five-spice later pledge possible.
|
||||
act('atreides', { type: 'bribe', target: donor, amount: 1 });
|
||||
reach(g => g.turn === 2 && g.phase === 1 && homeworldEcazClean(g));
|
||||
const wormAt = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed);
|
||||
assert.ok(wormAt >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(wormAt, 1)[0]);
|
||||
reach(g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g));
|
||||
const worm = structuredClone(game);
|
||||
act(donor, { type: 'alliance', target: payer });
|
||||
act(payer, { type: 'alliance', target: donor });
|
||||
const alliance = structuredClone(game);
|
||||
reach(g => g.phase === 3 && g.decision?.kind === (advanced ? 'richeseBlackMarket' : 'richeseDeclaration'));
|
||||
const choose = (values: Record<string, unknown>) => act(donor,
|
||||
{ type: 'decision', event: game.richeseBidding!.event, ...values } as Action);
|
||||
if (advanced) {
|
||||
if (source === 'blackMarket') choose({ card: richeseBureaucratPlayer(game, donor).hand[0].id, method: 'silent' });
|
||||
else choose({ decline: true });
|
||||
}
|
||||
if (source === 'cache') {
|
||||
choose({ position: 'first' });
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
choose({ card: game.richeseCache![0].id, method: 'silent' });
|
||||
}
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
assert.ok(game.richeseAuction && !game.richeseAuction.outcome);
|
||||
const staging = ['Conserved original unplayed worm moved to the next Spice reveal; actual worm alliance window retained.'];
|
||||
const counterActor = 'beneGesserit';
|
||||
let counterCard: string | undefined;
|
||||
if (options.counter) {
|
||||
const actor = richeseBureaucratPlayer(game, counterActor);
|
||||
const held = actor.hand.find(c => c.effect === 'karama');
|
||||
if (held) counterCard = held.id;
|
||||
else {
|
||||
const at = game.deck.findIndex(c => c.effect === 'karama');
|
||||
assert.ok(at >= 0 && actor.hand.length > 0);
|
||||
const original = actor.hand[0];
|
||||
actor.hand[0] = game.deck[at]; game.deck[at] = original;
|
||||
counterCard = actor.hand[0].id;
|
||||
staging.push(`Swap original unplayed ${counterCard} with held ${original.id}; retain hand size and physical stock.`);
|
||||
}
|
||||
}
|
||||
const primary = options.primary ?? 3, pledge = options.pledge ?? 5, price = primary + pledge;
|
||||
const beforePledge = structuredClone(game), physical = richeseBureaucratCards(game);
|
||||
act(donor, { type: 'pledgeAid', amount: pledge });
|
||||
const beforeAction = structuredClone(game), card = game.richeseAuction!.cardId;
|
||||
const action: Action = { type: 'richeseBid', event: game.richeseAuction!.event, amount: price, allyPayment: pledge };
|
||||
return { game, afterSetup, worm, alliance, beforePledge, beforeAction, actions, staging,
|
||||
source, advanced, payer, donor, seller: donor, trainer, emperor, counterActor, counterCard,
|
||||
banker: game.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!.owner,
|
||||
primary, pledge, price, card, action, physical };
|
||||
}
|
||||
export type RicheseBureaucratFixture = ReturnType<typeof createRicheseBureaucratFixture>;
|
||||
export function payRicheseBureaucrat(fixture: RicheseBureaucratFixture): Game {
|
||||
let game = applyAction(fixture.game, fixture.payer, fixture.action);
|
||||
for (const actor of game.richeseAuction!.order)
|
||||
if (actor !== fixture.payer) game = applyAction(game, actor,
|
||||
{ type: 'richeseBid', event: fixture.action.event, amount: 0 });
|
||||
return game;
|
||||
}
|
||||
export function chooseRicheseBureaucrat(game: Game, redirect: boolean): Game {
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
return applyAction(game, game.decision!.player,
|
||||
{ type: 'decision', event: game.bureaucratPaymentEvent, redirect });
|
||||
}
|
||||
Reference in new issue
Block a user