diff --git a/components/spice-banker-income.tsx b/components/spice-banker-income.tsx
index d09c50e9..4ddfa7b6 100644
--- a/components/spice-banker-income.tsx
+++ b/components/spice-banker-income.tsx
@@ -15,7 +15,8 @@ export function SpiceBankerIncome({ game }: { game: GameView }) {
This earned spice is not spendable yet. The original gaining faction collects it automatically
- at the current Mentat opening; it cannot fund a bid, shipment, revival or Battle Plan beforehand.
+ at the current Mentat opening; it cannot fund a bid, shipment, revival, Ambassador placement,
+ Kaitain disposal or Battle Plan beforehand.
Income timing and skill limits
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index d2dddbdb..6f473336 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,41 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Banker native placement/disposal — 27 October. Prototyped / Partial.**
+Accepted Ecaz Ambassador fees and high-Kaitain atomic disposal invoices now
+supply ordinary Banker income. Declaration/cancellation and self-payments earn
+nothing; separate small invoices do not aggregate. The physical phase cap,
+unavailable front-shield pile and original automatic Mentat collection remain.
+Kaitain retains its existing Bank-recipient/atomic-transaction interpretation.
+
+Frozen **17 affected files, 187/187**, types/lint/build and three native
+programmes pass. Fresh supported Skills preview initialization supplies actual
+Banker offers/training and World custody. Original cheap Emperor purchases
+supply held cards; actual poison disposal earns Ecaz's fifth-placement funding.
+No played hand, wallet, army, phase or future income receipt is installed.
+
+Runtime records 32 GameTable SSR renders, 16 placement renders,
+6 front-shield renders, 13 actual placement callbacks and 3 finish
+callbacks. 4 Kaitain actions use the native shared quote/engine; its stateful
+button was not operated. Recorded arrays supply aggregate source/further counts
+101/63; the preview initializer and inherited World-selection action
+are explicitly outside those recorded counts. No public-start claim is made.
+
+Twelve new native cases and three helper regressions cover actual thresholds,
+pending/denied/stale packets, real Karama cancellation, self-exclusion, a
+funded fifth placement, cross-phase grants and four legal Collection policies.
+Related feedback repairs three old Advanced Semuta fixture paths by completing
+one real Force Movement before Hajr; product timing is unchanged.
+
+The existing local server returned HTTP200 without restart. No HTTP/storage
+boundary, saved-game/database or deployed operation changes. This is local
+native/SSR/callback evidence, not browser hydration or complete modes.
+Wider interactions and final rules/recovery/privacy/deployed acceptance remain.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-banker-native-payments-proof-v2/`;
+journeys/HTML: adjacent `20261027-banker-native-payments-runtime/run/`.
+[Contract](SPICE_BANKER_RUNTIME.md#native-placement-and-disposal-payments--27-october-2026).
+
**Southern individual revival allocation — 27 October. Prototyped / Partial.**
Fresh Basic multi-Fedaykin returns permit separate portions/destinations or
keeping the remainder in reserves. One original grant/payment/income stays
diff --git a/docs/HOMEWORLD_CARD_ECONOMY_RULES.md b/docs/HOMEWORLD_CARD_ECONOMY_RULES.md
index 22cc6c49..dc83f6f0 100644
--- a/docs/HOMEWORLD_CARD_ECONOMY_RULES.md
+++ b/docs/HOMEWORLD_CARD_ECONOMY_RULES.md
@@ -43,6 +43,12 @@ selected card. A stale or invalid selection must spend nothing. The batch is an
implementation transaction, not a new printed once-only restriction. No extra
auction begins because disposal opened a hand slot after auctions ended.
+The 27 October Banker input connects this actual Bank invoice to the normal
+skill: two cards selected together cost four and may earn one deferred spice
+for a different eligible trainer. Two separate one-card payments do not add to
+four. Original disposal costs/custody and the phase cap stay authoritative.
+[Payment/collection contract](SPICE_BANKER_RUNTIME.md#native-placement-and-disposal-payments--27-october-2026).
+
The payment is disposal expenditure, not a card purchase, shipment or battle
support payment. No retrieved rule names a faction recipient: bank payment with
no commercial-income response is the implementation inference. Separate
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 80a9fb29..ab4653fb 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,41 @@
# Dune implementation status
+## 27 October 2026 — Banker placement and disposal Bank inputs
+
+Prototyped / Partial. Accepted Ecaz Ambassador fees and high-Kaitain atomic disposal invoices now
+supply ordinary Banker income. Declaration/cancellation and self-payments earn
+nothing; separate small invoices do not aggregate. The physical phase cap,
+unavailable front-shield pile and original automatic Mentat collection remain.
+Kaitain retains its existing Bank-recipient/atomic-transaction interpretation.
+
+Frozen **17 affected files, 187/187**, types/lint/build and three native
+programmes pass. Fresh supported Skills preview initialization supplies actual
+Banker offers/training and World custody. Original cheap Emperor purchases
+supply held cards; actual poison disposal earns Ecaz's fifth-placement funding.
+No played hand, wallet, army, phase or future income receipt is installed.
+
+Runtime records 32 GameTable SSR renders, 16 placement renders,
+6 front-shield renders, 13 actual placement callbacks and 3 finish
+callbacks. 4 Kaitain actions use the native shared quote/engine; its stateful
+button was not operated. Recorded arrays supply aggregate source/further counts
+101/63; the preview initializer and inherited World-selection action
+are explicitly outside those recorded counts. No public-start claim is made.
+
+Twelve new native cases and three helper regressions cover actual thresholds,
+pending/denied/stale packets, real Karama cancellation, self-exclusion, a
+funded fifth placement, cross-phase grants and four legal Collection policies.
+Related feedback repairs three old Advanced Semuta fixture paths by completing
+one real Force Movement before Hajr; product timing is unchanged.
+
+The existing local server returned HTTP200 without restart. No HTTP/storage
+boundary, saved-game/database or deployed operation changes. This is local
+native/SSR/callback evidence, not browser hydration or complete modes.
+Wider interactions and final rules/recovery/privacy/deployed acceptance remain.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-banker-native-payments-proof-v2/`;
+journeys/HTML: adjacent `20261027-banker-native-payments-runtime/run/`.
+[Contract](SPICE_BANKER_RUNTIME.md#native-placement-and-disposal-payments--27-october-2026).
+
## 27 October 2026 — Individual Southern revival allocation
Prototyped / Partial. Fresh Basic multi-Fedaykin roots retain one exact revival
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index fc96cd78..8c243f9a 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,16 @@
# Rule decision index
+## Banker on native placement and disposal — 27 October 2026
+
+Apply the printed normal Bank-four threshold to accepted Ecaz Ambassador fees
+and high-Kaitain disposal invoices. Declaration or cancellation supplies no
+paid Ecaz fee. Kaitain retains its existing inferred Bank recipient and atomic
+selected-group transaction; separate small payments do not aggregate. One
+physical Banker grants at most once in each phase and keeps the gain unavailable
+until native Mentat. This is ordinary skill/effect composition, not a new
+publisher combined-effect ruling or a retrospective award to old games.
+[Source and exact boundaries](SPICE_BANKER_RUNTIME.md#native-placement-and-disposal-payments--27-october-2026).
+
## Individual Southern revival allocation — 27 October 2026
Under the 14 October recommended-option authority, fresh Basic multi-Fedaykin
diff --git a/docs/SPICE_BANKER_RUNTIME.md b/docs/SPICE_BANKER_RUNTIME.md
index 81e5fd14..c09e32d7 100644
--- a/docs/SPICE_BANKER_RUNTIME.md
+++ b/docs/SPICE_BANKER_RUNTIME.md
@@ -1,5 +1,77 @@
# Spice Banker battle commitment and normal income prototypes
+## Native placement and disposal payments — 27 October 2026
+
+**Prototyped / Partial.** Ordinary Banker now receives two previously omitted
+native Bank invoices: an accepted Ecaz Ambassador placement in Revival and an
+accepted high-Kaitain selected-card disposal at Bidding's end. The photographed
+publisher Banker face, reread from the archived fourteen-card image, grants
+one deferred spice when another player pays at least four to the Bank, once
+per phase. It names no exclusion for these payment purposes. This applies the
+existing normal band; it introduces no new payment, optional claim or response.
+See [card provenance](LEADER_SKILLS_RULES.md#sources-and-local-evidence).
+
+Ecaz's current placement price is the next number in its native sequence.
+Declaring a cost-four placement does not pay it or grant income. Re-quote and
+commit the actual allowed four-spice Bank payment and physical token first;
+Karama denial spends no placement fee and grants no Banker spice. Prior
+one/two/three-spice placements remain separate invoices and do not add to four.
+Further qualifying placements in that phase reuse the physical card's cap.
+
+Kaitain retains its [existing atomic disposal transaction](HOMEWORLD_CARD_ECONOMY_RULES.md#kaitain-implementation-contract)
+and inferred Bank recipient: two selected cards cost four in one invoice,
+while two separate one-card actions pay two each and do not combine. Actual
+payment and physical discards precede the grant; later native discard children
+do not undo or replay that payment. Neither normal disposal nor its Banker
+grant opens a Karama window against the immune Homeworld power.
+
+Both sources use the existing version1 ledger and actual living native
+trainer, per-payer threshold and once-per-phase eligibility. A grant cannot
+finance its own fee or a later action. The public front-shield notice displays
+the unavailable pile; native Mentat collects it once. Existing historical
+receipts retain their format, and no old missed income is reconstructed.
+Foreign-ghola purchases already use their common paid-revival invoice; Rihani
+draw/exchange pays no Bank fee. This bounded source check does not certify
+every possible payment interaction or complete modes.
+
+Focused paths: [income rule](../game/spice-banker-income.ts),
+[native fixtures](../tests/fixture-banker-native-payments.ts),
+[native cases](../tests/banker-native-payments-runtime.test.ts),
+[public pile](../components/spice-banker-income.tsx).
+Accepted Ecaz Ambassador fees and high-Kaitain atomic disposal invoices now
+supply ordinary Banker income. Declaration/cancellation and self-payments earn
+nothing; separate small invoices do not aggregate. The physical phase cap,
+unavailable front-shield pile and original automatic Mentat collection remain.
+Kaitain retains its existing Bank-recipient/atomic-transaction interpretation.
+
+Frozen **17 affected files, 187/187**, types/lint/build and three native
+programmes pass. Fresh supported Skills preview initialization supplies actual
+Banker offers/training and World custody. Original cheap Emperor purchases
+supply held cards; actual poison disposal earns Ecaz's fifth-placement funding.
+No played hand, wallet, army, phase or future income receipt is installed.
+
+Runtime records 32 GameTable SSR renders, 16 placement renders,
+6 front-shield renders, 13 actual placement callbacks and 3 finish
+callbacks. 4 Kaitain actions use the native shared quote/engine; its stateful
+button was not operated. Recorded arrays supply aggregate source/further counts
+101/63; the preview initializer and inherited World-selection action
+are explicitly outside those recorded counts. No public-start claim is made.
+
+Twelve new native cases and three helper regressions cover actual thresholds,
+pending/denied/stale packets, real Karama cancellation, self-exclusion, a
+funded fifth placement, cross-phase grants and four legal Collection policies.
+Related feedback repairs three old Advanced Semuta fixture paths by completing
+one real Force Movement before Hajr; product timing is unchanged.
+
+The existing local server returned HTTP200 without restart. No HTTP/storage
+boundary, saved-game/database or deployed operation changes. This is local
+native/SSR/callback evidence, not browser hydration or complete modes.
+Wider interactions and final rules/recovery/privacy/deployed acceptance remain.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-banker-native-payments-proof-v2/`;
+journeys/HTML: adjacent `20261027-banker-native-payments-runtime/run/`.
+
+
## Mandatory ordinary Skills default — 17 October 2026
Fresh supported all-fourteen Leader Skills setups now include normal Banker
diff --git a/game/engine.ts b/game/engine.ts
index 35669b9f..73d2655e 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -26591,6 +26591,9 @@ function decideBiddingEnd(g: Game, p: Player, action: Action) {
p.spice -= quote.cost;
const entries = quote.cards.map((card) => ({ card: discard(g, p, card.id),
discardedBy: p.id, publicFace: false }));
+ awardBankerIncome(g, bankerIncomePayment(g, 'kaitain-disposal', [
+ { payer: p.id, amount: quote.cost, recipient: 'bank' },
+ ]));
log(g, `${p.name} paid ${quote.cost} spice to the bank and discarded ${entries.length} Treachery Card${entries.length === 1 ? '' : 's'} through high-population Kaitain. No new auction begins.`,
{ faction: p.faction, name: 'Kaitain disposal' });
stageTreacheryDiscard(g, 'kaitain', entries, { kind: 'kaitainDiscard',
@@ -33982,6 +33985,9 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
);
owner.spice -= quote.cost;
g.ecazAmbassadors = quote.state;
+ awardBankerIncome(g, bankerIncomePayment(g, 'ambassador-placement', [
+ { payer: owner.id, amount: quote.cost, recipient: 'bank' },
+ ]));
g.pendingEcazPlacement = null;
log(
g,
diff --git a/game/reference.ts b/game/reference.ts
index 06cef8bb..b36ebfad 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -6259,6 +6259,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The printed normal band is mandatory once per phase: when another player makes a payment of at least four spice to the Spice Bank, gain one spice and place it in front of the shield. It is an additional bank grant, not a reduction of the payer’s payment or another player’s income.',
'Only an actual completed positive bank payment qualifies. A payment to a player, zero/free/waived cost, self-payment, invalid declaration or unfinished possible payment supplies no income. Several smaller payments do not combine to reach four.',
'Use the original actual bank portion after recipient and counter settlement, not a quoted fee or guessed wallet change. Paid auctions, funded shipments, paid revival and eligible resolved battle support keep their native continuation once. Canceled Emperor, Guild or Tleilaxu revival income can leave a real bank payment. Tleilaxu self-revival pays the Bank; other-player revival normally pays Tleilaxu. Its separate free-revival bank award is not payer debit: three paid plus that one reward never becomes four paid to the Bank. A stopped shipment supplies no payment.',
+ 'Accepted Ecaz Ambassador placements and high-Kaitain selected-card disposals also use their actual Bank invoices. A fourth placement costs four; declaring it earns nothing until allowed and paid. An atomic two-card disposal costs four, while separate one-card payments of two never combine. Both keep the physical once-per-phase cap and original Mentat collection; deferred spice cannot fund another placement or disposal.',
'Actual payer contribution legs stay distinct. Two different players paying two each do not become one player paying four. The same payer’s Bureaucrat diversion and other final Bank portions of one original invoice do combine; unrelated invoices never do. Banker’s separate one-to-three battle payment does not combine with two support to manufacture a qualifying payment. Source conventions and user rulings are explicit, not a publisher split-payment example.',
'A new grant requires an actual available living native trainer. A captive grants no normal income. Hidden unselected skills do not supply their normal band; in the trainer’s own battle, its actual selected surviving leader supplies the appropriate eligibility after reveal/resolution, not private pre-reveal timing.',
'One physical Banker card has one use per turn and phase. Repeated payments, refresh, death, return or same-phase reassignment cannot reset that use; the next phase or turn permits a new grant.',
@@ -6270,11 +6271,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['leader-skills', 'battle', 'bidding', 'revival', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Implemented', detail: 'Fresh ordinary all-fourteen Skills initializes the existing deferred-income ledger automatically; original supported module and native overlay setup stays authoritative. Actual final Bank payer legs, one physical Banker phase grant and original-owner Mentat collection remain. Existing games are not retrofitted; the separate initializer/profile and opt-ins are removed.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts', 'game/leader-skill-profile.ts', 'game/homeworld-payment-income.ts', 'tools/prototype-room.ts'] },
+ { area: 'Implementation', status: 'Implemented', detail: 'Fresh ordinary all-fourteen Skills initializes the existing deferred-income ledger automatically; original supported module and native overlay setup stays authoritative. Accepted Ecaz placements and atomic Kaitain disposals join actual final Bank payer legs; one physical Banker phase grant and original-owner Mentat collection remain. Existing games are not retrofitted; the separate initializer/profile and opt-ins are removed.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts', 'game/leader-skill-profile.ts', 'game/homeworld-payment-income.ts', 'tools/prototype-room.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'A full-width read-only public front-shield notice explains unavailable spice and automatic collection; genuine original payment and final Collection controls work at 390px through refresh. No new income action or confirmation.', evidence: ['components/spice-banker-income.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies preserve identical available-budget actions with deferred income present or absent. Original wallets exclude front-shield currency; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'game/spice-banker.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed threshold/phase/collection, original payment separation, provisional earned custody and per-payer composition are explicit. The user-ruled same-payer diversion, primary-first private attribution and whole-invoice low rounding are documented without universal publisher claims.', evidence: ['docs/LEADER_SKILLS_RULES.md', 'docs/SPICE_BANKER_RUNTIME.md', 'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md', 'docs/RULE_DECISIONS.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Ordinary native World and Semuta programmes cover actual payment, physical trainer, deferred spendability, threshold/recipient exclusions, overlay continuation and once-only Mentat collection. Legacy played saves are not retrofitted. Two actual phone journeys and four legal policies exercise these contexts; this is not exhaustive combination, recovery, strategy or deployed acceptance.', evidence: ['tests/fixture-banker-world-default.ts', 'tests/banker-world-default.test.ts', 'tests/fixture-banker-overlay-default.ts', 'tests/banker-overlay-default.test.ts', 'tests/homeworld-banker-income.test.ts', 'tests/homeworld-banker-transport.test.ts', 'tests/homeworld-bank-payment.test.ts', 'tests/spice-banker-income.test.ts', 'tests/native-banker-income.test.ts', 'tests/prototype-room.test.ts', 'docs/SPICE_BANKER_RUNTIME.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Ordinary native World and Semuta programmes cover actual payment, physical trainer, deferred spendability, threshold/recipient exclusions, overlay continuation and once-only Mentat collection. Legacy played saves are not retrofitted. Two actual phone journeys and four legal policies exercise these contexts; this is not exhaustive combination, recovery, strategy or deployed acceptance.', evidence: ['tests/fixture-banker-world-default.ts', 'tests/banker-world-default.test.ts', 'tests/fixture-banker-overlay-default.ts', 'tests/banker-overlay-default.test.ts', 'tests/homeworld-banker-income.test.ts', 'tests/homeworld-banker-transport.test.ts', 'tests/homeworld-bank-payment.test.ts', 'tests/spice-banker-income.test.ts', 'tests/fixture-banker-native-payments.ts', 'tests/banker-native-payments-runtime.test.ts', 'tests/native-banker-income.test.ts', 'tests/prototype-room.test.ts', 'docs/SPICE_BANKER_RUNTIME.md'] },
],
},
{
diff --git a/game/spice-banker-income.ts b/game/spice-banker-income.ts
index b45b764c..5c80c9ba 100644
--- a/game/spice-banker-income.ts
+++ b/game/spice-banker-income.ts
@@ -8,6 +8,8 @@ export type BankerIncomeSourceKind =
| 'leader-revival'
| 'kh-revival'
| 'emperor-extra-revival'
+ | 'ambassador-placement'
+ | 'kaitain-disposal'
| 'battle-support';
export type BankerIncomeContext = {
turn: number;
@@ -66,7 +68,8 @@ export class BankerIncomeError extends Error {
const LIMIT = 50_000;
const sourcePhases: Record = {
auction: 3, shipment: 5, 'force-revival': 4, 'leader-revival': 4,
- 'kh-revival': 4, 'emperor-extra-revival': 4, 'battle-support': 6,
+ 'kh-revival': 4, 'emperor-extra-revival': 4, 'ambassador-placement': 4,
+ 'kaitain-disposal': 3, 'battle-support': 6,
};
const text = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= 512 && value.trim() === value;
diff --git a/tests/banker-native-payments-runtime.test.ts b/tests/banker-native-payments-runtime.test.ts
new file mode 100644
index 00000000..9a91dd83
--- /dev/null
+++ b/tests/banker-native-payments-runtime.test.ts
@@ -0,0 +1,228 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import type { Difficulty } from '../game/bot-profiles';
+import {
+ actBankerNativePayment as act, advanceBankerNativePayments as advance,
+ bankerEcazPlacementAction as placement, bankerKaitainAction as disposal,
+ bankerNativePaymentClean as clean, bankerNativePaymentDeferred as deferred,
+ bankerNativePaymentInventory as inventory, bankerNativePaymentPlayer as p,
+ bankerNativePaymentReload as reload, createBankerNativePaymentsFixture as fixture,
+ enterBankerEcazPlacement as enterPlacement, payBankerEcazPlacement as payPlacement,
+ settleBankerNativePayment as settle, type BankerNativePaymentStep,
+} from './fixture-banker-native-payments';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = reload(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+const sources = (game: Game, kind: 'ambassador-placement' | 'kaitain-disposal') =>
+ game.spiceBankerIncome!.sources.filter(row => row.source.kind === kind);
+const grants = (game: Game) => game.spiceBankerIncome!.sources.filter(row => row.grant);
+function payFour(game: Game, actions: BankerNativePaymentStep[] = []) {
+ game = enterPlacement(game, actions);
+ for (let cost = 1; cost <= 4; cost++) {
+ assert.equal(placement(game).cost, cost);
+ game = payPlacement(game, actions);
+ }
+ return game;
+}
+
+void test('native Banker payment source retains original preview training cards Worlds and actual one-spice purchase', () => {
+ const f = fixture();
+ assert.deepEqual(f.afterSetup.players.map(player => player.faction), ['guild', 'ecaz', 'emperor']);
+ assert.equal(f.afterSetup.advanced, false);
+ assert.equal(f.assignment.owner, 'guild');
+ assert.ok(p(f.afterSetup, 'guild').leaders.some(leader => leader.id === f.assignment.leader && !leader.dead));
+ assert.ok(f.setupChoiceSteps.some(step => step.actor === 'guild' && step.action.type === 'leaderSkill' && step.action.skill === 'spice-banker'));
+ assert.equal(f.sourceCounts.ready, f.readySteps.length);
+ assert.equal(f.sourceCounts.setupChoices, f.setupChoiceSteps.length);
+ assert.equal(f.sourceCounts.setup, f.setupSteps.length);
+ assert.equal(f.sourceCounts.source, f.actions.length);
+ assert.equal(f.game.phase, 3); assert.ok(f.game.biddingEnd);
+ assert.ok(f.invoices.length >= 1 && f.invoices.every(invoice => invoice.amount === 1));
+ assert.ok(f.purchased.includes(f.karama));
+ assert.ok(p(f.game, 'emperor').hand.some(card => card.id === f.karama));
+ assert.equal(deferred(f.game), 0);
+ assert.deepEqual(inventory(f.game), f.original);
+ assert.equal(f.original.cards.length, f.offered.deck.length + f.offered.players.reduce((total, player) => total + player.hand.length, 0));
+});
+
+void test('one actual high-Kaitain two-card four-spice invoice earns one deferred Banker spice', () => {
+ const f = fixture(), before = p(f.game, 'emperor').spice, owner = p(f.game, 'guild').spice;
+ const game = settle(act(reload(f.game), 'emperor', disposal(f.game, f.disposalCards)));
+ assert.equal(p(game, 'emperor').spice, before - 4);
+ assert.equal(p(game, 'guild').spice, owner);
+ assert.equal(deferred(game), 1);
+ assert.equal(sources(game, 'kaitain-disposal').length, 1);
+ assert.deepEqual(sources(game, 'kaitain-disposal')[0].source.bankLegs, [{ payer: 'emperor', amount: 4 }]);
+ assert.equal(sources(game, 'kaitain-disposal')[0].grant?.owner, 'guild');
+ for (const card of f.disposalCards) {
+ assert.equal(p(game, 'emperor').hand.some(held => held.id === card), false);
+ assert.equal(game.discard.filter(discarded => discarded.id === card).length, 1);
+ }
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('two native Kaitain one-card payments of two never aggregate into a qualifying invoice', () => {
+ const f = fixture(); let game = reload(f.game);
+ const before = p(game, 'emperor').spice;
+ for (const card of f.disposalCards) {
+ game = settle(act(game, 'emperor', disposal(game, [card])));
+ assert.equal(deferred(game), 0);
+ }
+ assert.equal(p(game, 'emperor').spice, before - 4);
+ assert.deepEqual(sources(game, 'kaitain-disposal').map(row => row.source.bankLegs),
+ [[{ payer: 'emperor', amount: 2 }], [{ payer: 'emperor', amount: 2 }]]);
+ assert.equal(sources(game, 'kaitain-disposal').some(row => row.grant), false);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('rejected wrong-owner duplicate-card and stale Kaitain selections never earn or replay income', () => {
+ const f = fixture(), packet = disposal(f.game, f.disposalCards);
+ reject(f.game, 'guild', packet);
+ reject(f.game, 'emperor', { ...packet, event: 'stale-end-of-bidding' });
+ reject(f.game, 'emperor', { ...packet, cards: [f.disposalCards[0], f.disposalCards[0]] });
+ assert.equal(deferred(f.game), 0);
+ const game = settle(act(reload(f.game), 'emperor', packet));
+ reject(game, 'emperor', packet);
+ assert.equal(deferred(game), 1);
+ assert.equal(sources(game, 'kaitain-disposal').length, 1);
+});
+
+void test('original Ecaz placement invoices one two and three earn nothing before the accepted fourth pays four', () => {
+ const f = fixture(); let game = enterPlacement(reload(f.game));
+ const ecaz = p(game, 'ecaz').spice, owner = p(game, 'guild').spice;
+ for (let cost = 1; cost <= 3; cost++) {
+ assert.equal(placement(game).cost, cost);
+ game = payPlacement(game);
+ assert.equal(deferred(game), 0);
+ }
+ assert.equal(p(game, 'ecaz').spice, ecaz - 6);
+ assert.equal(placement(game).cost, 4);
+ game = payPlacement(game);
+ assert.equal(p(game, 'ecaz').spice, ecaz - 10);
+ assert.equal(p(game, 'guild').spice, owner);
+ assert.equal(deferred(game), 1);
+ assert.deepEqual(sources(game, 'ambassador-placement').map(row => row.source.bankLegs),
+ [1, 2, 3, 4].map(amount => [{ payer: 'ecaz', amount }]));
+ assert.equal(sources(game, 'ambassador-placement').filter(row => row.grant).length, 1);
+ assert.equal(game.ecazAmbassadors!.tokens.filter(token => token.zone === 'placed').length, 4);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('declared fourth Ambassador cost remains unpaid and unawarded until its actual native response finishes', () => {
+ const f = fixture(); let game = enterPlacement(reload(f.game));
+ for (let count = 0; count < 3; count++) game = payPlacement(game);
+ const next = placement(game), before = p(game, 'ecaz').spice;
+ const paidSources = sources(game, 'ambassador-placement').length;
+ game = act(game, 'ecaz', next.action);
+ assert.equal(game.response?.kind, 'ecazPlacement');
+ assert.equal(game.pendingEcazPlacement?.cost, 4);
+ assert.equal(p(game, 'ecaz').spice, before);
+ assert.equal(deferred(game), 0);
+ assert.equal(sources(game, 'ambassador-placement').length, paidSources);
+ reject(game, 'ecaz', next.action);
+ assert.equal(deferred(game), 0);
+ game = settle(game);
+ assert.equal(p(game, 'ecaz').spice, before - 4);
+ assert.equal(deferred(game), 1);
+});
+
+void test('actual printed Karama cancellation of the fourth placement pays no four-spice invoice and grants no Banker reward', () => {
+ const f = fixture(); let game = enterPlacement(reload(f.game));
+ for (let count = 0; count < 3; count++) game = payPlacement(game);
+ const next = placement(game), before = p(game, 'ecaz').spice;
+ game = act(game, 'ecaz', next.action);
+ assert.equal(game.response?.kind, 'ecazPlacement');
+ assert.ok(p(game, 'emperor').hand.some(card => card.id === f.karama));
+ game = settle(act(game, 'emperor', { type: 'card', card: f.karama, mode: 'cancel' }));
+ assert.equal(p(game, 'ecaz').spice, before);
+ assert.equal(deferred(game), 0);
+ assert.equal(sources(game, 'ambassador-placement').length, 3);
+ assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === next.token)?.zone, 'supply');
+ assert.equal(game.ecazAmbassadors!.tokens.filter(token => token.zone === 'placed').length, 3);
+ assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('a paid placement cannot be repeated through an obsolete physical token declaration', () => {
+ const f = fixture(); let game = enterPlacement(reload(f.game));
+ for (let count = 0; count < 3; count++) game = payPlacement(game);
+ const packet = placement(game).action;
+ game = settle(act(game, 'ecaz', packet));
+ reject(game, 'ecaz', packet);
+ assert.equal(deferred(game), 1);
+ assert.equal(sources(game, 'ambassador-placement').length, 4);
+});
+
+void test('Ecaz owning the actual Banker never earns normal income from its own paid placements', () => {
+ const f = fixture({ banker: 'ecaz' }); const game = payFour(reload(f.game));
+ assert.equal(f.assignment.owner, 'ecaz');
+ assert.equal(deferred(game, 'ecaz'), 0);
+ assert.equal(sources(game, 'ambassador-placement').length, 4);
+ assert.equal(sources(game, 'ambassador-placement').some(row => row.grant), false);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('actual high-Ecaz poison-discard income funds a fifth placement but only one placement reward per phase', () => {
+ const f = fixture({ poisonFunding: true });
+ assert.ok(f.fundingCard && f.disposalCards.includes(f.fundingCard));
+ assert.ok(p(f.game, 'emperor').hand.some(card => card.id === f.fundingCard && card.kind === 'poison'));
+ const initial = p(f.game, 'ecaz').spice;
+ let game = settle(act(reload(f.game), 'emperor', disposal(f.game, f.disposalCards)));
+ assert.equal(p(game, 'ecaz').spice, initial + 3, 'The original high-Ecaz weapon-discard benefit funds the actual later cost');
+ game = payFour(game);
+ const before = p(game, 'ecaz').spice;
+ assert.ok(before >= 5); assert.equal(placement(game).cost, 5);
+ game = payPlacement(game);
+ assert.equal(p(game, 'ecaz').spice, before - 5);
+ assert.equal(sources(game, 'ambassador-placement').length, 5);
+ assert.equal(sources(game, 'ambassador-placement').filter(row => row.grant).length, 1);
+ assert.equal(deferred(game), 2, 'One phase-three disposal and one phase-four placement award remain distinct');
+ assert.equal(game.ecazAmbassadors!.tokens.filter(token => token.zone === 'placed').length, 5);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('one actual Kaitain invoice and the next phase placement earn two unavailable spice collected once at native Mentat', () => {
+ const f = fixture(), owner = p(f.game, 'guild').spice;
+ let game = settle(act(reload(f.game), 'emperor', disposal(f.game, f.disposalCards)));
+ game = payFour(game);
+ assert.equal(deferred(game), 2); assert.equal(p(game, 'guild').spice, owner);
+ assert.equal(grants(game).length, 2);
+ game = advance(game, state => state.phase === 8 && !state.phaseOpening);
+ assert.equal(deferred(game), 0);
+ assert.deepEqual(game.spiceBankerIncome!.collections.at(-1)?.credits, [{ owner: 'guild', amount: 2 }]);
+ const wallet = p(game, 'guild').spice;
+ const restored = reload(game);
+ assert.equal(p(restored, 'guild').spice, wallet);
+ assert.deepEqual(restored.spiceBankerIncome!.collections, game.spiceBankerIncome!.collections);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('all four minimal profiles finish real Collection into the same mandatory native Mentat credits', () => {
+ const f = fixture();
+ let paid = settle(act(reload(f.game), 'emperor', disposal(f.game, f.disposalCards)));
+ paid = payFour(paid);
+ const beforeCollection = advance(paid, state => state.phase === 7 && clean(state));
+ for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
+ let game = reload(beforeCollection), accepted = 0;
+ for (let count = 0; game.phase !== 8 && count < 30; count++) {
+ let progressed = false;
+ for (const player of game.players) {
+ const own = viewGame(game, player.id);
+ own.players.find(seat => seat.id === player.id)!.bot = difficulty;
+ const action = botActions(own)[0];
+ if (!action) continue;
+ game = act(game, player.id, action); accepted++; progressed = true; break;
+ }
+ assert.ok(progressed, `${difficulty} finishes its actual native Collection controls`);
+ }
+ assert.ok(accepted > 0); assert.equal(game.phase, 8);
+ assert.equal(deferred(game), 0);
+ assert.deepEqual(game.spiceBankerIncome!.collections.at(-1)?.credits, [{ owner: 'guild', amount: 2 }]);
+ assert.deepEqual(inventory(game), f.original);
+ }
+});
diff --git a/tests/fixture-banker-native-payments.ts b/tests/fixture-banker-native-payments.ts
new file mode 100644
index 00000000..80929572
--- /dev/null
+++ b/tests/fixture-banker-native-payments.ts
@@ -0,0 +1,173 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { ambassadorPlacementDestinations, quoteEcazAmbassadorPlacement } from '../game/ecaz-ambassador-placement';
+import { validateLeaderSkills } from '../game/leader-skills';
+import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
+import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
+import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+export type BankerNativePaymentStep = TruthMovementStep;
+export const actBankerNativePayment = actTruthMovement;
+export const bankerNativePaymentReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
+export const bankerNativePaymentPlayer = (game: Game, actor: string) => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export const bankerNativePaymentClean = (game: Game) => !game.response && !game.phaseOpening &&
+ !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
+export const bankerNativePaymentDeferred = (game: Game, owner = 'guild') =>
+ viewGame(game, owner).spiceBankerIncome!.deferred.find(row => row.owner === owner)?.amount ?? 0;
+
+export function bankerNativePaymentInventory(game: Game) {
+ homeworldGameIntegrity(game);
+ validateLeaderSkills(game.leaderSkills!, game.players);
+ const sum = (counts: Record) => Object.values(counts).reduce((a, b) => a + b, 0);
+ const worlds = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
+ for (const player of game.players) {
+ const visitors = worlds.filter(world => world.native !== player.id)
+ .map(world => world.forces[player.id] ?? { normal: 0, elite: 0 });
+ assert.equal(player.reserves + player.tanks + sum(player.forces) +
+ visitors.reduce((total, group) => total + group.normal + group.elite, 0), 20);
+ }
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
+ .map(card => card.id).sort();
+ const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
+ [...player.traitors, ...player.traitorChoices])].sort();
+ const skills = [...game.leaderSkills!.deck, ...game.leaderSkills!.assignments.map(assignment => assignment.skill),
+ ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards)].sort();
+ const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
+ assert.equal(new Set(cards).size, cards.length);
+ assert.equal(new Set(traitors).size, traitors.length);
+ assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort());
+ return { cards, traitors, skills, spice };
+}
+
+/** Only existing native controls. The caller owns unpaid placement declarations. */
+export function nextBankerNativePaymentStep(game: Game): BankerNativePaymentStep | null {
+ if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
+ if (game.biddingEnd) {
+ const actor = game.biddingEnd.owners.find(actor => !game.biddingEnd!.ready.includes(actor));
+ return actor ? { actor, action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' } } : null;
+ }
+ if (game.decision?.kind === 'ecazPlacement')
+ return { actor: game.decision.player, action: { type: 'decision', decline: true } };
+ }
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function advanceBankerNativePayments(game: Game, until: (state: Game) => boolean,
+ actions: BankerNativePaymentStep[] = []): Game {
+ for (let count = 0; count < 1200; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The genuine payment chronology ended before its requested boundary');
+ const step = nextBankerNativePaymentStep(game); assert.ok(step);
+ game = actBankerNativePayment(game, step.actor, step.action, actions);
+ }
+ throw Error('The native payment programme did not reach its finite boundary');
+}
+export function settleBankerNativePayment(game: Game, actions: BankerNativePaymentStep[] = []): Game {
+ return advanceBankerNativePayments(game, state => !state.response && !state.phaseOpening &&
+ !state.pendingTreacheryDiscard && !state.pendingKarama, actions);
+}
+export function bankerKaitainAction(game: Game, cards: readonly string[]): Action {
+ assert.ok(game.biddingEnd && bankerNativePaymentClean(game));
+ return { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'discard', cards: [...cards] };
+}
+export function enterBankerEcazPlacement(game: Game, actions: BankerNativePaymentStep[] = []): Game {
+ return advanceBankerNativePayments(game, state => state.phase === 4 && state.decision?.kind === 'ecazPlacement' &&
+ !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard, actions);
+}
+export function bankerEcazPlacementAction(game: Game): { action: Action; cost: number; token: string; territory: string } {
+ assert.equal(game.decision?.kind, 'ecazPlacement');
+ const token = game.ecazAmbassadors!.tokens.find(token => token.zone === 'supply'); assert.ok(token);
+ const destination = ambassadorPlacementDestinations(game)[0]; assert.ok(destination);
+ const quote = quoteEcazAmbassadorPlacement(game, bankerNativePaymentPlayer(game, 'ecaz'), token.id, destination.id);
+ return { action: { type: 'decision', token: token.id, territory: destination.id },
+ cost: quote.cost, token: token.id, territory: destination.id };
+}
+export function payBankerEcazPlacement(game: Game, actions: BankerNativePaymentStep[] = []): Game {
+ return settleBankerNativePayment(actBankerNativePayment(game, 'ecaz', bankerEcazPlacementAction(game).action, actions), actions);
+}
+
+/** Original Basic selected E3 roster, all14 Skills and native Homeworlds only.
+ * The supported preview initializer controls the original skill lottery, deals
+ * starting hands and accepts real training/Traitor/force choices. This adapter
+ * chooses only unopened next auction cards/Spice positions and actual bid1s.
+ * No played hand, cash, army, phase, population or income receipt is assigned.
+ */
+export function createBankerNativePaymentsFixture(options: { banker?: 'guild' | 'ecaz'; poisonFunding?: boolean } = {}) {
+ const banker = options.banker ?? 'guild';
+ const base = createIndependentCardModulesFixture({ advanced: false,
+ factions: ['guild', 'ecaz', 'emperor'], expansions: ['ecaz'], independent: false,
+ homeworlds: true, skills: true, tech: false, strongholds: false,
+ training: { guild: banker === 'guild' ? 'spice-banker' : 'warmaster',
+ ecaz: banker === 'ecaz' ? 'spice-banker' : 'warmaster', emperor: 'mentat' } });
+ let game = base.game;
+ const afterSetup = structuredClone(game), actions: BankerNativePaymentStep[] = [...base.steps];
+ const setupSteps = structuredClone(actions), original = bankerNativePaymentInventory(game);
+ assert.equal(game.advanced, false); assert.deepEqual(game.expansions, ['ecaz']);
+ assert.ok(game.homeworlds && game.leaderSkills && game.spiceBankerIncomePreview);
+ assert.ok(!game.ecazTreachery && !game.nexusCards && !game.discoveryEnabled && !game.techTokens && !game.strongholdCards);
+ const assignment = game.leaderSkills!.assignments.find(assignment => assignment.skill === 'spice-banker');
+ assert.ok(assignment && assignment.owner === banker);
+ const emperor = bankerNativePaymentPlayer(game, 'emperor');
+ const needSecond = options.poisonFunding || !emperor.hand.some(card => card.kind !== 'poison');
+ const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
+ const second = game.deck.find(card => options.poisonFunding ? card.kind === 'poison' : card.kind === 'shield'); assert.ok(second);
+ const unopened = [karama.id, ...(needSecond ? [second.id] : [])];
+ const auctionOrder = unopened.map((id, position) => {
+ const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
+ const [card] = game.deck.splice(from, 1); game.deck.splice(position, 0, card);
+ return { position, from, card: id };
+ });
+ const spiceOrder = [0].map(position => {
+ const from = game.spiceDeck.findIndex(card => 'territory' in card &&
+ !['imperial_basin', 'tueks_sietch'].includes(card.territory)); assert.ok(from >= position);
+ const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
+ return { position, from, card: structuredClone(card) };
+ });
+ const act = (actor: string, action: Action) => { game = actBankerNativePayment(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceBankerNativePayments(game, until, actions); };
+ reach(state => state.phase === 3 && !!state.auction && !state.response && !state.phaseOpening && !state.decision);
+ const beforeAuction = structuredClone(game), purchased: string[] = [], invoices: { card: string; amount: number }[] = [];
+ for (let lot = 0; lot < unopened.length; lot++) {
+ assert.equal(game.auction?.index, lot);
+ const card = game.auction!.cards[lot].id; assert.equal(card, unopened[lot]);
+ const before = bankerNativePaymentPlayer(game, 'emperor').spice;
+ let declared = false;
+ for (let count = 0; !bankerNativePaymentPlayer(game, 'emperor').hand.some(held => held.id === card) && count < 80; count++) {
+ if (!game.response && !game.phaseOpening && !game.decision) {
+ assert.ok(game.auction); const actor = game.auction.active;
+ if (actor === 'emperor' && !declared) { act(actor, { type: 'bid', amount: 1 }); declared = true; }
+ else act(actor, { type: 'passBid' });
+ } else {
+ const step = nextBankerNativePaymentStep(game); assert.ok(step); act(step.actor, step.action);
+ }
+ }
+ assert.ok(declared); assert.ok(bankerNativePaymentPlayer(game, 'emperor').hand.some(held => held.id === card));
+ const amount = before - bankerNativePaymentPlayer(game, 'emperor').spice;
+ assert.equal(amount, 1); purchased.push(card); invoices.push({ card, amount });
+ }
+ reach(state => !!state.biddingEnd && bankerNativePaymentClean(state));
+ assert.equal(game.phase, 3);
+ const held = bankerNativePaymentPlayer(game, 'emperor').hand;
+ assert.ok(held.some(card => card.id === karama.id));
+ const fundingCard = options.poisonFunding ? held.find(card => card.id === second.id) : undefined;
+ const disposalCards = fundingCard
+ ? [fundingCard.id, held.find(card => card.id !== fundingCard.id)!.id]
+ : held.filter(card => card.kind !== 'poison').slice(0, 2).map(card => card.id);
+ assert.equal(disposalCards.length, 2);
+ assert.equal(bankerNativePaymentDeferred(game, banker), 0, 'Only actual one-spice auction invoices have occurred');
+ assert.deepEqual(bankerNativePaymentInventory(game), original);
+ return { initial: base.initial, offered: base.offered, afterSetup, beforeAuction, game, banker,
+ assignment: structuredClone(assignment), karama: karama.id, purchased, invoices, disposalCards,
+ fundingCard: fundingCard?.id ?? null, auctionOrder, spiceOrder, original, setupSteps,
+ actions, readySteps: setupSteps.filter(step => step.action.type === 'ready'),
+ setupChoiceSteps: setupSteps.filter(step => step.action.type !== 'ready'),
+ sourceCounts: { ready: setupSteps.filter(step => step.action.type === 'ready').length,
+ setupChoices: setupSteps.filter(step => step.action.type !== 'ready').length,
+ setup: setupSteps.length, source: actions.length },
+ sourceDisclosure: 'Supported initializeLeaderSkillsGameForAudit preview initialization of original Basic Guild/Ecaz/Emperor all14 training and Worlds, not a public start action. Inherited recorded steps include actual Ready/setup choices; the earlier World option selection and initializer are not counted as actions. Only original unopened Karama/optional poison auction positions and Spice order are selected, followed by actual Emperor bid1/spice invoices, all-pass remainder and high-Kaitain closing controls. No installed played state or future income.' };
+}
diff --git a/tests/fixture-banker-overlay-default.ts b/tests/fixture-banker-overlay-default.ts
index 4cb064ad..9360c11c 100644
--- a/tests/fixture-banker-overlay-default.ts
+++ b/tests/fixture-banker-overlay-default.ts
@@ -4,7 +4,7 @@ import {
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
-import { botEntryAllowed } from '../game/bot-mobility';
+import { botEntryAllowed, botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
import { reserveShipmentCost } from '../game/shipment-price';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import type { Card } from '../game/cards';
@@ -28,7 +28,7 @@ export type BankerOverlayOptions = {
semuta?: BankerOverlaySemuta;
};
export type BankerOverlayPlan = {
- /** Play Atreides' conserved printed Hajr when its own movement begins. */
+ /** Basic plays at own movement; Advanced first completes an actual movement. */
hajr?: boolean;
/** Seat committing its own printed Semuta Drug at that fresh discard; null passes. */
claim?: string | null;
@@ -154,6 +154,20 @@ export function nextBankerOverlayNativeStep(
const holder = game.players.find(player => player.id === 'a' && player.id === game.active &&
!game.hajr.includes(player.id) && player.hand.some(card => card.effect === 'hajr'));
if (holder) {
+ if (game.advanced && (holder.hajrWindow?.turn !== game.turn || holder.hajrWindow.move !== holder.moved)) {
+ assert.equal(holder.moved, 0,
+ 'Earn the original immediate Hajr opportunity with the still-unused first Force Movement.');
+ const own = viewGame(game, holder.id), seat = own.players.find(player => player.id === holder.id)!;
+ const movement = Object.entries(holder.forces).filter(([key, count]) => count > (holder.elites?.forces[key] ?? 0))
+ .flatMap(([from]) => TERRITORIES.filter(destination => destination.type === 'sand' &&
+ !game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${destination.id}:`))) &&
+ !Object.keys(game.spice).some(key => key.startsWith(`${destination.id}:`) && game.spice[key] > 0))
+ .flatMap(destination => destination.sectors.map(sector => ({ from, territory: destination.id, sector }))))
+ .find(move => groundMoveRouteDistance(own, seat, move.from, `${move.territory}:${move.sector}`) === 1 &&
+ botGroundMoveAllowed(own, seat, move.from, `${move.territory}:${move.sector}`, 0));
+ assert.ok(movement, 'The original Atreides army must have a clear adjacent empty sand destination.');
+ return { actor: holder.id, action: { type: 'move', ...movement, amount: 1, elite: 0 } };
+ }
const card = holder.hand.find(candidate => candidate.effect === 'hajr')!;
return { actor: holder.id, action: { type: 'card', card: card.id } };
}
@@ -378,11 +392,13 @@ export function shipBankerOverlayIncome(fixture: BankerOverlayFixture): Game {
return advanceBankerOverlay(game, bankerOverlayClean, { hajr: false, claim: null });
}
-/** Reach the holder's own movement turn, play the conserved printed Hajr, and
- * assert the registered Semuta overlay offers its reaction on that fresh discard. */
-export function openBankerOverlayHajr(state: Game, fixture: BankerOverlayFixture): Game {
+/** Reach the holder's own movement turn, complete the first real movement in
+ * Advanced, then play Hajr immediately after its original arrival finishes.
+ * Basic retains its original timing. No immediate-use window is installed. */
+export function openBankerOverlayHajr(state: Game, fixture: BankerOverlayFixture,
+ actions?: RicheseSkillsTechStep[]): Game {
const game = advanceBankerOverlay(state, candidate => !!candidate.pendingTreacheryDiscard,
- { hajr: true, claim: null });
+ { hajr: true, claim: null }, actions);
const pending = game.pendingTreacheryDiscard;
assert.ok(pending, 'The conserved printed Hajr must produce one real fresh discard.');
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
diff --git a/tests/spice-banker-income.test.ts b/tests/spice-banker-income.test.ts
index fd82492e..0ffdb0b6 100644
--- a/tests/spice-banker-income.test.ts
+++ b/tests/spice-banker-income.test.ts
@@ -149,7 +149,8 @@ void test('concealed battle trainer needs actual revealed selection and resolved
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],
+ ['kh-revival', 4], ['emperor-extra-revival', 4], ['ambassador-placement', 4],
+ ['kaitain-disposal', 3], ['battle-support', 6],
] as const;
for (const [kind, phase] of kinds) {
const ctx = context(1, phase);
@@ -161,6 +162,79 @@ void test('all named native producer kinds are phase-bound and empty bank settle
assert.throws(() => validateBankerIncomeSource(ctx, authority), BankerIncomeError);
}
});
+void test('Ambassador placement and Kaitain disposal require one atomic four-spice other-payer BANK leg', () => {
+ for (const [kind, phase] of [['ambassador-placement', 4], ['kaitain-disposal', 3]] as const) {
+ const ctx = context(1, phase);
+ let state = createBankerIncomeState(ctx);
+ for (const event of ['first-two', 'second-two']) {
+ state = earn(state, ctx, { ...source(ctx, event, 2), kind });
+ }
+ assert.deepEqual(projectBankerIncome(state, ctx), { deferred: [], usedThisPhase: [] });
+ const split = { ...source(ctx, 'two-other-payers'), kind };
+ split.bankLegs = [{ payer: 'b', amount: 2 }, { payer: 'c', amount: 2 }];
+ state = earn(state, ctx, split);
+ assert.equal(state.sources[2].grant, null);
+ state = earn(state, ctx, { ...source(ctx, 'atomic-four'), kind });
+ state = earn(state, ctx, { ...source(ctx, 'later-five', 5), kind });
+ assert.equal(state.sources[3].grant?.amount, 1);
+ assert.equal(state.sources[4].grant, null);
+ assert.deepEqual(projectBankerIncome(state, ctx), {
+ deferred: [{ owner: 'a', amount: 1 }], usedThisPhase: ['a'],
+ });
+ }
+});
+void test('new native BANK sources do not pay the owner, an unrevealed trainer or a captive normal band', () => {
+ for (const [kind, phase] of [['ambassador-placement', 4], ['kaitain-disposal', 3]] as const) {
+ const ctx = context(1, phase);
+ const state = createBankerIncomeState(ctx);
+ const self = { ...source(ctx, 'own-bank-payment', 4, 'a'), kind };
+ const noBankPayment = { ...source(ctx, 'no-bank-leg', 0), kind };
+ const noTrainer = { ...source(ctx, 'no-trainer'), kind, trainer: null };
+ const unrevealed = { ...source(ctx, 'unrevealed-trainer'), kind };
+ unrevealed.trainer!.faceUp = false;
+ for (const authority of [self, noBankPayment, noTrainer, unrevealed]) {
+ assert.equal(quoteBankerIncome(state, ctx, authority).receipt.grant, null);
+ }
+ const captiveContext = context(1, phase);
+ captiveContext.players[0].leaders[0].capturedBy = 'b';
+ const captive = { ...source(captiveContext, 'captive-trainer'), kind };
+ captive.trainer!.captured = true;
+ assert.equal(quoteBankerIncome(state, captiveContext, captive).receipt.grant, null);
+ const absentContext = context(1, phase);
+ absentContext.assignments = [];
+ immutableReject(state, () => quoteBankerIncome(state, absentContext, { ...source(ctx), kind }));
+ }
+});
+void test('Kaitain, new Ecaz placement and shipment earn once per phase into the same deferred Mentat pile', () => {
+ const auctionContext = context(1, 3);
+ let state = earn(createBankerIncomeState(auctionContext), auctionContext,
+ { ...source(auctionContext, 'kaitain-four'), kind: 'kaitain-disposal' });
+ state = earn(state, auctionContext, source(auctionContext, 'later-auction-five', 5));
+ assert.equal(state.sources[1].grant, null);
+ const revivalContext = context(1, 4);
+ state = earn(state, revivalContext,
+ { ...source(revivalContext, 'ecaz-placement-four'), kind: 'ambassador-placement' });
+ state = earn(state, revivalContext,
+ { ...source(revivalContext, 'later-leader-revival-five', 5), kind: 'leader-revival' });
+ assert.equal(state.sources[3].grant, null);
+ const movementContext = context(1, 5);
+ for (const event of ['first-shipment-five', 'later-shipment-five']) {
+ state = earn(state, movementContext, { ...source(movementContext, event, 5), kind: 'shipment' });
+ }
+ assert.equal(state.sources[5].grant, null);
+ assert.equal(state.version, 1);
+ assert.deepEqual(projectBankerIncome(state, movementContext), {
+ deferred: [{ owner: 'a', amount: 3 }], usedThisPhase: ['a'],
+ });
+ immutableReject(state, () => quoteBankerIncomeCollection(state, movementContext, opening));
+ const mentat = context(1, 8);
+ const restored = JSON.parse(JSON.stringify(state)) as BankerIncomeState;
+ validateBankerIncomeState(restored, mentat);
+ const collection = quoteBankerIncomeCollection(restored, mentat, opening);
+ assert.deepEqual(collection.credits, [{ owner: 'a', amount: 3 }]);
+ assert.deepEqual(collection.collection.grants, [1, 3, 5]);
+ assert.deepEqual(projectBankerIncome(collection.next, mentat), { deferred: [], usedThisPhase: [] });
+});
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));