Credit Banker for paid placement and disposal invoices
This commit is contained in:
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);
|
||||
}
|
||||
});
|
||||
@@ -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.' };
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user