Credit Banker for paid placement and disposal invoices

This commit is contained in:
Quintine committed 2026-10-27 09:38:37 +11:00
1 parent ff2826b980
commit c56d9c85ef
13 files changed
+673 -11

No files matched your search

@@ -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);
}
});
+173
View File
@@ -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<string, number>) => 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.' };
}
+22 -6
View File
@@ -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');
+75 -1
View File
@@ -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));