Make normal Banker income mandatory in fresh Skills setups

This commit is contained in:
Quintine committed 2026-10-17 12:58:29 +11:00
1 parent 628f59b73a
commit 3ed9856578
43 files changed
+1244 -263

No files matched your search

@@ -51,10 +51,10 @@ function holdPhysicalTraitor(game: Game, identity: string): void {
/** All14 and the original assassination state come from the fresh production
* initializer. Only board/card positions are controlled after native setup. */
function stagedGame(bankerIncome = false) {
function stagedGame() {
const native = completedAdvancedNativeSkillsGame({
family: 'moritani', skillOwner: 'guild', requestedSkill: 'warmaster',
opponents: ['guild', 'emperor'], bankerIncome,
opponents: ['guild', 'emperor'],
});
assert.equal(assignment(native, player(native, 'guild').id).skill, 'warmaster');
assert.ok(native.moritaniAssassinate);
@@ -332,8 +332,8 @@ void test('an actual normal Moritani loss cannot assassinate when the winning tr
custody(game);
});
void test('the fresh private Banker wrapper retains normal-traitor ENTIRE-GAME forfeiture through a later native turn loss', () => {
let game = stagedGame(true);
void test('ordinary Skills retain normal-traitor ENTIRE-GAME forfeiture through a later native turn loss', () => {
let game = stagedGame();
const guild = player(game, 'guild').id;
const moritani = player(game, 'moritani').id;
const trainer = assignment(game, guild).leader;
+190
View File
@@ -0,0 +1,190 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction } from '../game/engine';
import {
advanceBankerOverlayToMentat, advanceBankerOverlayToPhase, bankerOverlayDeferred,
bankerOverlayInventory, bankerOverlayLedger, bankerOverlayPlayer, bankerOverlayWallets,
claimBankerOverlaySemuta, createBankerOverlayFixture, openBankerOverlayHajr,
passBankerOverlaySemuta, shipBankerOverlayIncome, type BankerOverlayOptions,
} from './fixture-banker-overlay-default';
const profiles: BankerOverlayOptions[] = [
{ advanced: false, tech: false, strongholds: false },
{ advanced: true, tech: false, strongholds: false },
{ advanced: true, tech: true, strongholds: false },
{ advanced: true, tech: true, strongholds: true },
];
for (const profile of profiles) {
const label = `${profile.advanced ? 'Advanced' : 'Basic'}${profile.tech ? ' Tech' : ''}${profile.strongholds ? ' Strongholds' : ''}`;
void test(`${label} Semuta + Skills: the ordinary Richese Banker keeps its printed income for a real Guild self-shipment paid to the Bank until the native Mentat Pause`, () => {
const fixture = createBankerOverlayFixture(profile);
const before = bankerOverlayWallets(fixture.game);
const payer = before.find(row => row.id === fixture.payer)!;
const owner = before.find(row => row.id === fixture.owner)!;
assert.equal(fixture.kind, 'bank');
assert.equal(fixture.amount, 4);
assert.equal(fixture.cost, 4, 'Four normal forces into empty sand cost four spice at the Guild half rate');
assert.equal(fixture.payee, 'bank');
assert.equal(fixture.grant, true);
assert.equal(fixture.game.semutaPreview, true);
assert.equal(fixture.game.spiceBankerIncomePreview, true);
assert.equal(bankerOverlayLedger(fixture.game, fixture.owner).preview, true);
assert.deepEqual(bankerOverlayLedger(fixture.game, fixture.owner).deferred, [],
'No income exists before the real payment');
assert.equal(payer.shipped, false);
assert.equal(fixture.game.active, fixture.payer);
const paid = shipBankerOverlayIncome(fixture);
const ledger = bankerOverlayLedger(paid, fixture.owner);
const receipt = paid.spiceBankerIncome!.sources.at(-1)!;
assert.equal(paid.spiceBankerIncome!.sources.length, 1, 'Only the one real Bank payment is recorded');
assert.equal(paid.spiceBankerIncome!.collections.length, 0);
assert.equal(receipt.source.kind, 'shipment');
assert.equal(receipt.source.turn, 1);
assert.equal(receipt.source.phase, 5);
assert.deepEqual(receipt.source.bankLegs, [{ payer: fixture.payer, amount: fixture.cost }],
'The deferred invoice keeps the exact real Bank leg of this payment');
assert.equal(receipt.grant!.owner, fixture.owner, 'Another payer paid the Bank, so the printed grant is Richese’s');
assert.equal(receipt.grant!.amount, 1);
assert.deepEqual(receipt.source.trainer!.assignment,
{ skill: 'spice-banker', leader: fixture.trainer, owner: fixture.owner });
assert.equal(receipt.source.trainer!.faceUp, true, 'The actual Banker trainer must be face up');
assert.equal(receipt.source.trainer!.survived, true);
assert.deepEqual(ledger.deferred, [{ owner: fixture.owner, amount: 1 }],
'The printed spice sits in front of the shield, unavailable until the Mentat Pause');
assert.deepEqual(ledger.usedThisPhase, [fixture.owner]);
assert.equal(bankerOverlayDeferred(paid, fixture.owner), 1);
// The original payment itself is unchanged and already spent this phase.
const after = bankerOverlayWallets(paid);
assert.equal(after.find(row => row.id === fixture.payer)!.spice, payer.spice - 4);
assert.equal(after.find(row => row.id === fixture.payer)!.reserves, payer.reserves - 4);
assert.equal(after.find(row => row.id === fixture.payer)!.shipped, true);
assert.equal(after.find(row => row.id === fixture.payer)!.moved, payer.moved);
assert.equal(after.find(row => row.id === fixture.owner)!.spice, owner.spice,
'Deferred income is not spendable and must not change the Banker’s wallet');
assert.equal(bankerOverlayPlayer(paid, fixture.payer).forces[fixture.key], fixture.amount);
assert.equal(paid.response, null, 'The Guild’s own shipment owes no Guild income response');
assert.throws(() => applyAction(paid, fixture.payer, fixture.shipment),
'The same native shipment cannot repeat its payment or income');
// The printed overlay continuation runs on a real fresh discard, then the
// original ordinary-card continuation resumes with the same income untouched.
const paused = openBankerOverlayHajr(paid, fixture);
const beforeOverlay = bankerOverlayLedger(paused, fixture.owner);
const claimed = claimBankerOverlaySemuta(paused, fixture);
assert.deepEqual(bankerOverlayLedger(claimed, fixture.owner), beforeOverlay,
'Claiming through the original Semuta overlay must not create, move or duplicate Banker income');
assert.deepEqual(claimed.spiceBankerIncome, paused.spiceBankerIncome);
const collection = advanceBankerOverlayToPhase(claimed, 7, fixture.plan);
assert.deepEqual(bankerOverlayLedger(collection, fixture.owner).deferred, [{ owner: fixture.owner, amount: 1 }],
'The printed spice must still be deferred through the ordinary Collection phase');
const balance = bankerOverlayPlayer(collection, fixture.owner).spice;
const mentat = advanceBankerOverlayToMentat(collection, fixture.plan);
const mentatLedger = bankerOverlayLedger(mentat, fixture.owner);
assert.deepEqual(mentatLedger.deferred, [], 'The actual Mentat Pause collects the deferred spice');
assert.deepEqual(mentatLedger.usedThisPhase, []);
assert.equal(bankerOverlayPlayer(mentat, fixture.owner).spice, balance + 1,
'The Mentat Pause credits exactly the one printed spice');
assert.equal(mentat.spiceBankerIncome!.collections.length, 1);
assert.deepEqual(mentat.spiceBankerIncome!.collections[0].credits, [{ owner: fixture.owner, amount: 1 }]);
assert.deepEqual(mentat.spiceBankerIncome!.collections[0].grants, [receipt.cursor]);
assert.equal(mentat.spiceBankerIncome!.sources.length, 1);
assert.equal(mentat.pendingTreacheryDiscard, null);
assert.equal(mentat.response, null);
assert.equal(mentat.decision, null);
assert.equal(mentat.status, 'playing');
// Original overlay and card custody complete: the claimed card stays claimed.
assert.equal(bankerOverlayPlayer(mentat, fixture.owner).hand.filter(card => card.id === fixture.hajr).length, 1);
assert.equal(mentat.discard.filter(card => card.id === fixture.semuta).length, 1);
assert.deepEqual(bankerOverlayInventory(mentat), bankerOverlayInventory(fixture.game));
});
}
void test('Basic Semuta + Skills: the same real Guild self-shipment below the printed band records no grant', () => {
const fixture = createBankerOverlayFixture({ kind: 'below', semuta: 'pass' });
assert.equal(fixture.amount, 3);
assert.equal(fixture.cost, 3);
assert.equal(fixture.payee, 'bank');
assert.equal(fixture.grant, false);
const before = bankerOverlayWallets(fixture.game);
const payer = before.find(row => row.id === fixture.payer)!;
const owner = before.find(row => row.id === fixture.owner)!;
const paid = shipBankerOverlayIncome(fixture);
assert.equal(paid.spiceBankerIncome!.sources.length, 1,
'The real Bank payment is still recorded, without the printed grant');
assert.equal(paid.spiceBankerIncome!.sources[0].grant, null);
assert.deepEqual(paid.spiceBankerIncome!.sources[0].source.bankLegs, [{ payer: fixture.payer, amount: 3 }]);
assert.deepEqual(bankerOverlayLedger(paid, fixture.owner).deferred, []);
assert.deepEqual(bankerOverlayLedger(paid, fixture.owner).usedThisPhase, []);
assert.equal(bankerOverlayPlayer(paid, fixture.payer).spice, payer.spice - 3);
const paused = openBankerOverlayHajr(paid, fixture);
const passed = passBankerOverlaySemuta(paused, fixture);
assert.deepEqual(passed.spiceBankerIncome, paid.spiceBankerIncome,
'Declining the printed overlay must not create Banker income');
const collection = advanceBankerOverlayToPhase(passed, 7, fixture.plan);
const balance = bankerOverlayPlayer(collection, fixture.owner).spice;
assert.equal(balance, owner.spice,
'This Richese holds no board forces, so ordinary Collection does not change its wallet');
const mentat = advanceBankerOverlayToMentat(collection, fixture.plan);
assert.equal(bankerOverlayPlayer(mentat, fixture.owner).spice, balance,
'The Mentat Pause credits nothing without a printed grant');
assert.equal(mentat.spiceBankerIncome!.collections.length, 1);
assert.deepEqual(mentat.spiceBankerIncome!.collections[0].credits, []);
assert.deepEqual(mentat.spiceBankerIncome!.collections[0].grants, []);
assert.equal(mentat.discard.filter(card => card.id === fixture.hajr).length, 1);
assert.equal(bankerOverlayPlayer(mentat, fixture.owner).hand.filter(card => card.id === fixture.semuta).length, 1);
assert.deepEqual(bankerOverlayInventory(mentat), bankerOverlayInventory(fixture.game));
});
void test('Basic Semuta + Skills: another payer’s real four-spice shipment to the seated Guild player is not a Bank payment', () => {
const fixture = createBankerOverlayFixture({ kind: 'player' });
assert.equal(fixture.payer, 'a');
assert.equal(fixture.payerFaction, 'atreides');
assert.equal(fixture.amount, 2);
assert.equal(fixture.cost, 4, 'Two normal forces at the full desert price still pay four spice');
assert.equal(fixture.payee, 'guild');
assert.equal(fixture.grant, false);
const before = bankerOverlayWallets(fixture.game);
const atreides = before.find(row => row.id === 'a')!;
const guild = before.find(row => row.id === 'g')!;
const paid = shipBankerOverlayIncome(fixture);
assert.equal(paid.spiceBankerIncome!.sources.length, 0,
'A price paid to a player is not a Bank payment, so no invoice is recorded');
assert.equal(paid.spiceBankerIncome!.collections.length, 0);
assert.deepEqual(bankerOverlayLedger(paid, fixture.owner).deferred, []);
assert.equal(bankerOverlayPlayer(paid, 'a').spice, atreides.spice - 4);
assert.equal(bankerOverlayPlayer(paid, 'g').spice, guild.spice + 4,
'The seated Guild still receives its ordinary shipment income');
assert.equal(bankerOverlayPlayer(paid, 'a').shipped, true);
assert.equal(bankerOverlayPlayer(paid, 'a').forces[fixture.key], 2);
const paused = openBankerOverlayHajr(paid, fixture);
const claimed = claimBankerOverlaySemuta(paused, fixture);
assert.equal(claimed.spiceBankerIncome!.sources.length, 0);
const collection = advanceBankerOverlayToPhase(claimed, 7, fixture.plan);
const balance = bankerOverlayPlayer(collection, fixture.owner).spice;
const mentat = advanceBankerOverlayToMentat(collection, fixture.plan);
assert.equal(bankerOverlayPlayer(mentat, fixture.owner).spice, balance);
assert.deepEqual(mentat.spiceBankerIncome!.collections[0].credits, []);
assert.equal(mentat.spiceBankerIncome!.collections.length, 1);
});
void test('Basic Semuta + Skills: supplied Banker authority on the ordinary native shipment is rejected without change', () => {
const fixture = createBankerOverlayFixture({ semuta: 'pass' });
const before = structuredClone(fixture.game);
for (const key of ['actor', 'bankerIncome', 'paymentSource', 'spiceBankerIncome', 'bankLegs', 'trainer']) {
assert.throws(() => applyAction(fixture.game, fixture.payer,
{ ...fixture.shipment, [key]: 'supplied' }));
assert.deepEqual(fixture.game, before);
}
const paid = shipBankerOverlayIncome(fixture);
assert.deepEqual(paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,
[{ payer: fixture.payer, amount: fixture.cost }],
'Only the original native transaction can supply the deferred Bank legs');
assert.throws(() => applyAction(paid, fixture.payer, fixture.shipment));
assert.equal(paid.spiceBankerIncome!.sources.length, 1);
});
+154
View File
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { quotePhaseResources } from '../game/phase-resource-quote';
import { nativeShipmentSources } from '../game/homeworld-options';
import {
advanceBankerWorldDefault, bankerWorldBeforeMentat, bankerWorldClean, bankerWorldPlayer,
bankerWorldWallets, createBankerWorldDefaultFixture, payBankerWorldDefault,
} from './fixture-banker-world-default';
void test('ordinary all14 native World: explicit Guild homeworld withdrawal pays Bank four and defers one without Heighliners income', () => {
const f = createBankerWorldDefaultFixture(), before = f.game;
assert.equal(before.status, 'playing');
assert.equal(before.turn, 1);
assert.equal(before.phase, 5);
assert.equal(before.active, f.payer);
assert.ok(bankerWorldClean(before));
assert.equal(bankerWorldPlayer(before, f.payer).shipped, false);
assert.ok(before.homeworlds?.custody && before.nexusCards?.cards && before.discoveries && before.strongholdCards);
const trained = before.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!;
assert.equal(trained.owner, f.owner);
assert.equal(trained.leader, f.trainer);
assert.equal(bankerWorldPlayer(before, f.owner).faction, 'richese');
assert.equal(bankerWorldPlayer(before, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.equal(f.shipment.actor, f.payer);
assert.deepEqual(f.shipment.action.homeworldSources, { 'homeworld:guild': { normal: 4, elite: 0 } });
assert.deepEqual(f.shipment.action.homeworldSources, nativeShipmentSources(viewGame(before, f.payer), 4, 0));
assert.equal(f.shipment.action.type, 'ship');
assert.equal(f.shipment.action.amount, 4);
assert.equal(f.shipment.action.elite, 0);
const key = `${String(f.shipment.action.territory)}:${String(f.shipment.action.sector)}`;
assert.ok(before.players.every(p => !p.forces[key]));
assert.deepEqual(viewGame(before, f.owner).spiceBankerIncome!.deferred, []);
const wallets = bankerWorldWallets(before), reserves = bankerWorldPlayer(before, f.payer).reserves;
const paid = payBankerWorldDefault(f);
assert.deepEqual(bankerWorldWallets(paid), { ...wallets, [f.payer]: wallets[f.payer] - 4 });
assert.equal(bankerWorldPlayer(paid, f.payer).reserves, reserves - 4);
assert.equal(bankerWorldPlayer(paid, f.payer).forces[key], 4);
assert.equal(bankerWorldPlayer(paid, f.payer).shipped, true);
assert.equal(paid.phase, 5);
assert.equal(paid.active, f.payer);
assert.equal(paid.response, null);
// Guild's own off-planet shipment is explicitly excluded by Heighliners.
// Tech custody/accrual is not reassigned to make this experiment pay out.
assert.deepEqual(paid.techTokens, before.techTokens);
assert.equal(paid.techTokens!.heighliners.spice, 0);
const sources = paid.spiceBankerIncome!.sources.slice(before.spiceBankerIncome!.sources.length);
assert.equal(sources.length, 1);
assert.equal(sources[0].source.kind, 'shipment');
assert.equal(sources[0].source.turn, 1);
assert.equal(sources[0].source.phase, 5);
assert.deepEqual(sources[0].source.bankLegs, [{ payer: f.payer, amount: 4 }]);
assert.equal(sources[0].source.trainer!.assignment.leader, f.trainer);
assert.equal(sources[0].source.trainer!.faceUp, true);
assert.equal(sources[0].source.trainer!.captured, false);
assert.equal(sources[0].grant!.owner, f.owner);
assert.equal(sources[0].grant!.amount, 1);
assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome, {
deferred: [{ owner: f.owner, amount: 1 }], usedThisPhase: [f.owner],
});
});
void test('pending normal Banker spice cannot fund a real bribe; the final original Collection ready collects exactly once at Mentat 1', () => {
const f = createBankerWorldDefaultFixture(), paid = payBankerWorldDefault(f);
const owner = bankerWorldPlayer(paid, f.owner), controls = viewGame(paid, f.owner).bribeOptions;
assert.equal(controls.blocked, null);
assert.equal(controls.available, owner.spice);
assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
const frozen = structuredClone(paid);
assert.throws(() => applyAction(paid, f.owner,
{ type: 'bribe', target: 'emperor', amount: owner.spice + 1 }), /Spice/);
assert.deepEqual(paid, frozen);
// Same legal native action/target succeeds at the actual wallet boundary.
const affordable = applyAction(paid, f.owner, { type: 'bribe', target: 'emperor', amount: owner.spice });
assert.equal(bankerWorldPlayer(affordable, f.owner).spice, 0);
assert.equal(bankerWorldPlayer(affordable, 'emperor').bribes, owner.spice);
assert.deepEqual(viewGame(affordable, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
// Continue the unmodified paid branch: no synthetic army, ownership, phase or
// wallet edits, and no bribe receipt enters this Mentat income baseline.
const boundary = bankerWorldBeforeMentat(paid);
assert.equal(boundary.beforeCollection.phase, 5);
assert.equal(boundary.collectionAction.action.type, 'endMovement');
assert.equal(boundary.collectionAction.actor, boundary.beforeCollection.active);
assert.ok(bankerWorldClean(boundary.beforeCollection));
// All native Tech/World shipment effects have already settled. Collection's
// independently quoted stronghold/desert money is distinct from the front.
assert.deepEqual(quotePhaseResources(boundary.beforeCollection).credits, []);
const nativeCollection = quoteSpiceCollection(boundary.beforeCollection);
assert.deepEqual(bankerWorldWallets(boundary.collection),
Object.fromEntries(nativeCollection.receipts.map(r => [r.player, r.balance])));
assert.deepEqual(viewGame(boundary.collection, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]);
assert.equal(boundary.game.turn, 1);
assert.equal(boundary.game.phase, 7);
assert.ok(bankerWorldClean(boundary.game));
assert.equal(boundary.game.ready.length, boundary.game.players.length - 1);
assert.deepEqual(boundary.action.action, { type: 'ready' });
assert.ok(!boundary.game.ready.includes(boundary.action.actor));
assert.deepEqual(bankerWorldWallets(boundary.game), bankerWorldWallets(boundary.collection));
assert.equal(boundary.game.spiceBankerIncome!.collections.length, 0);
const wallets = bankerWorldWallets(boundary.game);
const mentat = applyAction(boundary.game, boundary.action.actor, boundary.action.action);
assert.equal(mentat.turn, 1);
assert.equal(mentat.phase, 8);
assert.deepEqual(bankerWorldWallets(mentat), { ...wallets, [f.owner]: wallets[f.owner] + 1 });
assert.deepEqual(viewGame(mentat, f.owner).spiceBankerIncome!.deferred, []);
assert.equal(mentat.spiceBankerIncome!.collections.length, 1);
const collection = mentat.spiceBankerIncome!.collections[0];
assert.equal(collection.turn, 1);
assert.equal(collection.phase, 8);
assert.deepEqual(collection.credits, [{ owner: f.owner, amount: 1 }]);
assert.deepEqual(collection.grants, mentat.spiceBankerIncome!.sources.filter(r => r.grant).map(r => r.cursor));
const nextTurn = advanceBankerWorldDefault(mentat, g => g.turn === 2 && g.phase === 0 && bankerWorldClean(g));
assert.deepEqual(nextTurn.spiceBankerIncome!.collections, mentat.spiceBankerIncome!.collections);
assert.deepEqual(bankerWorldWallets(nextTurn), bankerWorldWallets(mentat));
assert.deepEqual(viewGame(nextTurn, f.owner).spiceBankerIncome!.deferred, []);
});
void test('ordinary World Guild self-shipment below four still debits Bank and withdraws physical homeworld forces, but grants nothing', () => {
const f = createBankerWorldDefaultFixture(), wallets = bankerWorldWallets(f.game);
const sources = nativeShipmentSources(viewGame(f.game, f.payer), 3, 0);
assert.ok(sources);
const paid = payBankerWorldDefault(f, { ...f.shipment.action, amount: 3, homeworldSources: sources });
assert.deepEqual(bankerWorldWallets(paid), { ...wallets, [f.payer]: wallets[f.payer] - 3 });
assert.equal(bankerWorldPlayer(paid, f.payer).reserves, bankerWorldPlayer(f.game, f.payer).reserves - 3);
assert.equal(bankerWorldPlayer(paid, f.payer).forces[`${String(f.shipment.action.territory)}:${String(f.shipment.action.sector)}`], 3);
const receipt = paid.spiceBankerIncome!.sources.at(-1)!;
assert.equal(receipt.source.kind, 'shipment');
assert.deepEqual(receipt.source.bankLegs, [{ payer: f.payer, amount: 3 }]);
assert.equal(receipt.grant, null);
assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, []);
assert.deepEqual(paid.techTokens, f.game.techTokens);
});
void test('a played ordinary Skills save without income metadata keeps its legacy payment and Mentat behavior', () => {
const f = createBankerWorldDefaultFixture();
// Recreate the pre-cutover serialized shape, not a fresh setup option.
const legacy = structuredClone(f.game);
delete legacy.spiceBankerIncomePreview;
delete legacy.spiceBankerIncome;
const wallets = bankerWorldWallets(legacy);
const paid = payBankerWorldDefault({ ...f, game: legacy });
assert.deepEqual(bankerWorldWallets(paid), { ...wallets, [f.payer]: wallets[f.payer] - 4 });
assert.equal(paid.spiceBankerIncomePreview, undefined);
assert.equal(paid.spiceBankerIncome, undefined);
assert.equal(viewGame(paid, f.owner).spiceBankerIncome, null);
const boundary = bankerWorldBeforeMentat(paid);
const mentat = applyAction(boundary.game, boundary.action.actor, boundary.action.action);
assert.equal(mentat.phase, 8);
assert.deepEqual(bankerWorldWallets(mentat), bankerWorldWallets(boundary.game));
assert.equal(mentat.spiceBankerIncome, undefined);
});
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, initializeSpiceBankerIncomeGameForAudit, viewGame } from '../game/engine';
import { applyAction, initializeLeaderSkillsGameForAudit, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { validateLeaderSkills } from '../game/leader-skills';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
@@ -84,7 +84,7 @@ void test('Nexus Banker keeps available wallet budgets and existing legal policy
assert.equal(nexusBankerPlayer(next, f.owner).spice, nexusBankerPlayer(f.paid, f.owner).spice);
assert.deepEqual(viewGame(next, f.owner).spiceBankerIncome!.deferred, [{owner:'a',amount:1}]);
const snapshot = structuredClone(f.paid);
assert.throws(()=>initializeSpiceBankerIncomeGameForAudit(f.paid));
assert.throws(()=>initializeLeaderSkillsGameForAudit(f.paid));
assert.deepEqual(f.paid,snapshot);
assert.throws(()=>applyAction(f.paid,f.owner,{type:'collectSpiceBankerIncome'}));
assert.deepEqual(f.paid,snapshot);
@@ -80,7 +80,7 @@ void test('a fresh authenticated original lobby retains human IDs and input stat
lobby = applyAction(lobby, lobby.host, { type: 'strongholdCards', enabled: true });
lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
const original = structuredClone(lobby);
const fixture = createClassicSkillsStrongholdFixture({ initial: lobby, optionalTech: true, bankerIncome: true, ownerDial: 6, support: 6 });
const fixture = createClassicSkillsStrongholdFixture({ initial: lobby, optionalTech: true, ownerDial: 6, support: 6 });
assert.deepEqual(lobby, original);
assert.deepEqual(fixture.initial.players.map(seat => seat.id), original.players.map(seat => seat.id));
assert.equal(fixture.owner, 'human-guild');
@@ -99,7 +99,7 @@ for (const scenario of [
{ support: 6, dial: 6, own: 4, casualties: 6 },
]) {
void test(`Arrakeen original support ${scenario.support}: actual own ${scenario.own}, not bank-funded support, controls Banker while trained Suk rescues physical losses`, () => {
const fixture = createClassicSkillsStrongholdFixture({ bankerIncome: true, skill: 'suk-graduate', band: 'skilled',
const fixture = createClassicSkillsStrongholdFixture({ skill: 'suk-graduate', band: 'skilled',
ownerDial: scenario.dial, support: scenario.support, ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
const revealed = revealClassicSkillsStrongholdBattle(fixture);
const quote = quoteClassicSkillsStrongholdBattle(revealed);
@@ -138,7 +138,7 @@ for (const scenario of [
}
void test('four real policies physically choose original Suk rescue before winner-card cleanup and the actual losing-owner Tech reward', () => {
const fixture = createClassicSkillsStrongholdFixture({ optionalTech: true, bankerIncome: true, skill: 'suk-graduate', band: 'skilled',
const fixture = createClassicSkillsStrongholdFixture({ optionalTech: true, skill: 'suk-graduate', band: 'skilled',
ownerDial: 6, support: 6, ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
const revealed = revealClassicSkillsStrongholdBattle(fixture);
const originalTokens = ownedTech(revealed.techTokens, fixture.opponent);
+3 -4
View File
@@ -54,10 +54,9 @@ void test('Discovery cannot redeal started components or silently remove another
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
const overlays = Array.from({ length: 2 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].advanced = false; overlays[0].strongholdCards = createStrongholdCards();
overlays[1].spiceBankerIncomePreview = true;
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
const basicStrongholds = lobby(['guild', 'emperor', 'harkonnen'], []);
basicStrongholds.advanced = false; basicStrongholds.strongholdCards = createStrongholdCards();
assert.throws(() => initializeLeaderSkillsGameForAudit(basicStrongholds), RuleError);
const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens();
assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError);
});
@@ -68,7 +68,6 @@ for (const advanced of [false, true]) for (const tech of [false, true]) {
assert.ok(player(f.game, f.richese).hand.some(c => c.id === f.green));
assert.equal(f.game.richeseCache!.some(c => c.id === f.green), false);
assert.ok(f.game.richeseCache!.length > 0, 'Two-turn bounded recipes do not bypass the exhausted-cache ruling.');
assert.equal(f.game.spiceBankerIncomePreview, undefined);
if (tech) {
assert.deepEqual(f.setup.techTokens, createTechTokens());
assert.ok(TECH_TOKENS.every(token => f.afterFirstStorm.techTokens![token.id].owner !== null));
+2 -2
View File
@@ -102,8 +102,8 @@ void test('all four real legal policies rescue trained native Ecaz counters and
}
});
void test('real Arrakeen subsidy and separately opted-in normal Banker count only the opposing paid Bank leg', () => {
const f = fixture({ kind: 'income', band: 'normal', bankerIncome: true, tech: true, stronghold: true, opponentDial: 4, defense: true });
void test('real Arrakeen subsidy and ordinary normal Banker count only the opposing paid Bank leg', () => {
const f = fixture({ kind: 'income', band: 'normal', tech: true, stronghold: true, opponentDial: 4, defense: true });
const before = reveal(f), sources = before.spiceBankerIncome!.sources.length;
const game = settle(before, 'cards');
assert.equal(game.lastBattleContext!.winner, f.owner);
+2 -3
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer,
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
@@ -14,7 +14,6 @@ export type AdvancedNativeSkillsOptions = {
requestedSkill?: LeaderSkillId;
skillOwner?: FactionId;
opponents?: readonly FactionId[];
bankerIncome?: boolean;
/** Richese and Ecaz also exercise their Basic native rule bands. */
rules?: 'basic' | 'advanced';
/** Continue this actual authenticated lobby/setup without replacing its seats. */
@@ -76,7 +75,7 @@ export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkill
assert.equal(game.status, 'lobby');
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withSkillShuffle(options.requestedSkill ?? 'suk-graduate', game.players.findIndex(p => p.id === owner.id),
() => options.bankerIncome ? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game));
() => initializeLeaderSkillsGameForAudit(game));
}
/** Finish original setup decisions from the actual own-seat projections. */
+439
View File
@@ -0,0 +1,439 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { botEntryAllowed } from '../game/bot-mobility';
import { reserveShipmentCost } from '../game/shipment-price';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import type { Card } from '../game/cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type BankerOverlayKind = 'bank' | 'below' | 'player';
export type BankerOverlaySemuta = 'claim' | 'pass';
export type BankerOverlayOptions = {
/** Basic by default; Advanced adds the Guild's own native shipment stop. */
advanced?: boolean;
/** Optional original Tech Tokens in the same ordinary Skills setup. */
tech?: boolean;
/** Optional original Advanced Stronghold Cards. */
strongholds?: boolean;
/** Which real native shipment actually funds the Bank. */
kind?: BankerOverlayKind;
/** Conserved normal reserve forces; the printed desert price follows the payer's rate. */
amount?: number;
/** Conserved printed Semuta reaction at the real Hajr discard. */
semuta?: BankerOverlaySemuta;
};
export type BankerOverlayPlan = {
/** Play Atreides' conserved printed Hajr when its own movement begins. */
hajr?: boolean;
/** Seat committing its own printed Semuta Drug at that fresh discard; null passes. */
claim?: string | null;
};
export type BankerOverlayFixture = {
/** Real phase 5 position: the payer is active and has not shipped yet. */
game: Game;
/** Fresh ready lobby before the ordinary Semuta Skills initializer. */
initial: Game;
/** Immediately after that initializer: ledger stamped, overlays undealt. */
setup: Game;
/** Same position as `game`, before any conserved printed-card staging. */
beforePayment: Game;
actions: RicheseSkillsTechStep[];
staging: string[];
owner: string;
trainer: string;
payer: string;
payerFaction: Player['faction'];
holder: string;
semuta: string;
hajr: string;
territory: string;
sector: number;
key: string;
amount: number;
cost: number;
/** Actual printed recipient of the real shipment price. */
payee: 'bank' | 'guild';
/** Printed entitlement of this exact payment. */
grant: boolean;
kind: BankerOverlayKind;
advanced: boolean;
tech: boolean;
strongholds: boolean;
shipment: Action;
/** Original Advanced Guild stop, acknowledged before any payment. */
allow: RicheseSkillsTechStep | null;
plan: BankerOverlayPlan;
};
export function bankerOverlayPlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing original ${id} seat`);
return player;
}
/** No pending opening, response, decision or committed fresh discard. */
export const bankerOverlayClean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** One physical slot per original Treachery identity, including the Richese cache. */
export function bankerOverlayInventory(game: Game): string[] {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'One physical slot per original Treachery identity');
return ids;
}
/** Wallets, counters, clocks, flags and skill custody; original hands excluded. */
export function bankerOverlayPosition(game: Game) {
return structuredClone({
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
order: game.order, ready: game.ready, movementRemaining: game.movementRemaining,
storm: game.storm, hajr: game.hajr, pendingShipment: game.pendingShipment,
karamaShipping: game.karamaShipping, guildRateBlocked: game.guildRateBlocked,
response: game.response, decision: game.decision, leaderSkills: game.leaderSkills,
players: game.players.map(player => ({
id: player.id, faction: player.faction, ally: player.ally, spice: player.spice,
bribes: player.bribes, forces: player.forces, reserves: player.reserves, tanks: player.tanks,
elites: player.elites, leaders: player.leaders, shipped: player.shipped, moved: player.moved,
revived: player.revived, traitors: player.traitors,
})),
});
}
export function bankerOverlayWallets(game: Game) {
return game.players.map(player => ({
id: player.id, faction: player.faction, spice: player.spice, bribes: player.bribes,
reserves: player.reserves, tanks: player.tanks, shipped: player.shipped, moved: player.moved,
hand: player.hand.map(card => card.id),
}));
}
/** Own-view ledger projection plus the committed history, for exact comparisons. */
export function bankerOverlayLedger(game: Game, owner: string) {
const projection = viewGame(game, owner).spiceBankerIncome;
assert.ok(projection, `The ordinary Skills overlay must keep ${owner}'s Spice Banker income projection.`);
return {
preview: game.spiceBankerIncomePreview === true,
state: structuredClone(game.spiceBankerIncome ?? null),
deferred: projection.deferred,
usedThisPhase: projection.usedThisPhase,
};
}
export function bankerOverlayDeferred(game: Game, owner: string): number {
return bankerOverlayLedger(game, owner).deferred.find(row => row.owner === owner)?.amount ?? 0;
}
/** Existing ordinary native policy plus the two genuine overlay continuations:
* the original Advanced Guild timing choice and the printed Semuta reaction. */
export function nextBankerOverlayNativeStep(
game: Game, plan: BankerOverlayPlan = {},
): RicheseSkillsTechStep {
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
if (pending && reaction?.stage === 'offer') {
const event = pending.batch.event;
const claim = plan.claim ?? null;
if (claim && !reaction.passed.includes(claim)) {
const own = viewGame(game, claim).semutaReaction;
if (own?.stage === 'offer' && own.canCommit) return { actor: claim, action: { type: 'semutaCommit', event } };
}
const actor = game.players.find(player => !reaction.passed.includes(player.id));
assert.ok(actor, 'The neutral Semuta offer must keep one original waiting seat.');
return { actor: actor.id, action: { type: 'semutaPass', event } };
}
if (pending && reaction?.stage === 'select') {
const card = reaction.candidates[0];
assert.ok(card, 'A committed Semuta claim must keep one private fresh candidate.');
return { actor: reaction.player, action: { type: 'semutaSelect', event: pending.batch.event, card } };
}
// Conserved own movement: the Guild may take the current combined turn; the
// ordinary order then resumes with the same physical queue.
if (!game.response && !game.phaseOpening && game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: true } };
if (plan.hajr && game.status === 'playing' && game.phase === 5 && bankerOverlayClean(game)) {
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) {
const card = holder.hand.find(candidate => candidate.effect === 'hajr')!;
return { actor: holder.id, action: { type: 'card', card: card.id } };
}
}
return nextRicheseSkillsTechNativeStep(game);
}
function step(game: Game, plan: BankerOverlayPlan, actions?: RicheseSkillsTechStep[]): Game {
const next = nextBankerOverlayNativeStep(game, plan);
actions?.push(next);
return applyAction(game, next.actor, next.action);
}
/** Apply only original native actions until the requested boundary holds. */
export function advanceBankerOverlay(
state: Game, until: (game: Game) => boolean, plan: BankerOverlayPlan = {},
actions?: RicheseSkillsTechStep[],
): Game {
let game = state;
for (let n = 0; !until(game) && n < 1500; n++) {
assert.notEqual(game.status, 'finished', 'The genuine native path must reach its requested boundary');
game = step(game, plan, actions);
}
assert.ok(until(game), 'The genuine native path must reach its requested boundary');
return game;
}
export function advanceBankerOverlayToPhase(
state: Game, phase: number, plan: BankerOverlayPlan = {}, actions?: RicheseSkillsTechStep[],
): Game {
let game = state;
const turn = state.turn;
for (let n = 0; (game.phase !== phase || !bankerOverlayClean(game)) &&
game.turn === turn && game.status === 'playing' && n < 1500; n++) game = step(game, plan, actions);
assert.equal(game.turn, turn);
assert.equal(game.phase, phase);
assert.ok(bankerOverlayClean(game));
return game;
}
/** Stop at the real native Mentat Pause entry; its own opening has already collected. */
export function advanceBankerOverlayToMentat(
state: Game, plan: BankerOverlayPlan = {}, actions?: RicheseSkillsTechStep[],
): Game {
let game = state;
const turn = state.turn;
for (let n = 0; (game.phase !== 8 || game.phaseOpening) &&
game.turn === turn && game.status === 'playing' && n < 1500; n++) game = step(game, plan, actions);
assert.equal(game.turn, turn);
assert.equal(game.phase, 8);
return game;
}
/** Fix the initial all14 offer and native setup scalar entropy; leave UUIDs native.
* The printed Spice Banker lands in the first seat's own two-card offer. */
export function withBankerOverlaySkillOffer<T>(initialize: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
const target = LEADER_SKILL_CARDS.findIndex(card => card.id === 'spice-banker');
assert.ok(target >= 0, 'The original all14 deck must keep the Spice Banker card.');
let index = LEADER_SKILL_CARDS.length - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (!(array instanceof Uint32Array)) return Reflect.apply(original, crypto, [array]) as V;
array.fill(0xffffffff);
if (index > 0) {
assert.equal(array.length, 1, 'The initial all14 shuffle uses single scalar draws.');
array[0] = index === target ? 0 : 0xffffffff;
index--;
}
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Conserve one canonical printed card into its owner's hand from the first
* original slot already holding it; never mint a second identity. */
function takePrintedCard(
game: Game, actor: string, matches: (card: Card) => boolean, staging: string[],
): string {
const holder = bankerOverlayPlayer(game, actor);
const held = holder.hand.find(matches);
if (held) return held.id;
assert.ok(holder.hand.length < handLimit(holder),
'The conserved hand must keep room for its own printed overlay card.');
const zones: [Card[], string][] = [
[game.deck, 'undealt Treachery deck'],
[game.discard, 'discard pile'],
[game.richeseCache ?? [], 'Richese cache'],
];
for (const [zone, name] of zones) {
const index = zone.findIndex(matches);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
holder.hand.push(card);
staging.push(`Conserved canonical ${card.id} from the ${name} into ${actor}'s hand.`);
return card.id;
}
const donor = game.players.find(player => player.id !== actor && player.hand.some(matches));
assert.ok(donor, 'The requested printed overlay card must keep one original custody slot.');
const index = donor.hand.findIndex(matches);
const card = donor.hand.splice(index, 1)[0];
holder.hand.push(card);
staging.push(`Conserved canonical ${card.id} from ${donor.id}'s original dealt hand into ${actor}'s hand.`);
return card.id;
}
/** Genuine Basic/Advanced Semuta + Skills setup, first turn and the payer's own
* Movement turn. Initial setup scalar entropy, the first two unplayed Spice Cards
* and two conserved printed overlay cards are controlled and explicitly disclosed.
* Wallets, counters, clocks, turns, phases, skills, receipts and assignments stay native. */
export function createBankerOverlayFixture(options: BankerOverlayOptions = {}): BankerOverlayFixture {
const kind = options.kind ?? 'bank';
const advanced = options.advanced ?? false;
const tech = options.tech ?? false;
const strongholds = options.strongholds ?? false;
const semuta = options.semuta ?? 'claim';
const payee = kind === 'player' ? 'guild' as const : 'bank' as const;
assert.ok(advanced || !strongholds, 'Original Stronghold Cards require Advanced rules.');
let game = createGame('BANKEROVERLAY', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('g', 'Guild', 'guild'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
if (tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
const initial = structuredClone(game);
// The ordinary Semuta Skills delegate stamps the mandatory income ledger first,
// then sets its own original overlay flag.
game = withBankerOverlaySkillOffer(() => initializeSemutaGameForAudit(game, true));
assert.equal(game.semutaPreview, true, 'The original Semuta overlay flag must remain set.');
assert.equal(game.spiceBankerIncomePreview, true,
'Ordinary Leader Skills setup must stamp the income ledger before the overlay flag.');
assert.ok(game.spiceBankerIncome, 'The fresh ordinary setup must commit one income ledger.');
assert.ok(viewGame(game, 'r').spiceBankerIncome, 'Each own seat must project that ledger.');
assert.ok(game.leaderSkills?.offers['r']?.cards.includes('spice-banker'),
'The controlled original all14 shuffle must offer the Spice Banker to the first seat.');
const setup = structuredClone(game);
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
staging.push('Initial all14 offers and native setup scalar entropy are controlled; UUIDs remain native. Components are not replaced.');
const plan: BankerOverlayPlan = { hajr: false, claim: null };
for (let n = 0; game.status === 'setup' && n < 250; n++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = offer.cards.includes('spice-banker') ? 'spice-banker'
: offer.cards.find(card => !own.unavailableSkills?.[card]);
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill],
'The actual original offer must supply one legal skill.');
const eligible = new Set(own.eligibleLeaders.map(leader => leader.id));
const leader = bankerOverlayPlayer(game, actor).leaders.filter(disc => eligible.has(disc.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader, 'The actual original living eligible disc must supply training.');
const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: leader.id };
actions.push({ actor, action });
game = applyAction(game, actor, action);
} else game = step(game, plan, actions);
}
assert.equal(game.status, 'playing');
const assignment = game.leaderSkills!.assignments.find(entry => entry.skill === 'spice-banker');
assert.ok(assignment, 'The ordinary native setup must assign the real Spice Banker skill.');
const owner = assignment.owner;
assert.equal(owner, 'r', 'The real Spice Banker must be trained by the first offered Richese seat.');
assert.equal(bankerOverlayPlayer(game, owner).leaders.find(leader => leader.id === assignment.leader)!.dead, false,
'The actual Banker trainer must still be alive.');
// Prevent an incidental first-turn worm by conserving the first two unplayed blows.
for (const position of [0, 1]) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
assert.ok(index >= position, 'One original unplayed Spice Card must remain');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
staging.push('Conserved only the first two original unplayed Spice Cards as ordinary land blows.');
const payer = kind === 'player' ? 'a' : 'g';
const amount = options.amount ?? (kind === 'below' ? 3 : kind === 'player' ? 2 : 4);
game = advanceBankerOverlay(game, candidate => candidate.phase === 5 && candidate.turn === 1 &&
bankerOverlayClean(candidate) && candidate.active === payer &&
!bankerOverlayPlayer(candidate, payer).shipped, plan, actions);
const seat = bankerOverlayPlayer(game, payer);
const halfRate = seat.faction === 'guild';
const own = viewGame(game, payer);
// Empty ordinary sand isolates this paid shipment from battle and arrival parents;
// the real consumer entry helpers still choose the exact legal action.
const destination = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm) &&
!game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${t.id}:`))) &&
botEntryAllowed(own, own.players.find(player => player.id === payer)!, t.id, t.sectors[0], 'ship'));
assert.ok(destination, 'One original legal empty sand destination must remain');
const sector = destination.sectors[0];
const cost = reserveShipmentCost({ faction: seat.faction, halfRate }, destination.type, amount);
assert.equal(cost, halfRate ? amount : amount * 2,
'The printed shipment price must follow the payer’s own ordinary rate.');
assert.ok(seat.reserves >= amount, 'The payer must hold that many ordinary reserves.');
assert.ok((seat.spice ?? 0) >= cost, 'The payer must hold the printed shipment price.');
const shipment: Action = { type: 'ship', territory: destination.id, sector, amount, elite: 0, allyPayment: 0 };
const beforePayment = structuredClone(game);
// The two conserved printed overlay cards: Richese's own Semuta and the printed Hajr.
const semutaId = takePrintedCard(game, owner, card => card.id === SEMUTA_DRUG_ID, staging);
const hajrId = takePrintedCard(game, 'a', card => card.effect === 'hajr', staging);
assert.deepEqual(bankerOverlayPosition(game), bankerOverlayPosition(beforePayment),
'Conserved printed-card staging must not move wallets, counters, clocks or skills.');
assert.deepEqual(bankerOverlayInventory(game), bankerOverlayInventory(beforePayment),
'Conserved printed-card staging must not duplicate or replace a physical identity.');
return {
game, initial, setup, beforePayment, actions, staging,
owner, trainer: assignment.leader, payer, payerFaction: seat.faction, holder: 'a',
semuta: semutaId, hajr: hajrId, territory: destination.id, sector,
key: `${destination.id}:${sector}`, amount, cost, payee, grant: payee === 'bank' && cost >= 4,
kind, advanced, tech, strongholds, shipment,
allow: advanced ? { actor: 'g', action: { type: 'decision', allow: true } } : null,
plan: { hajr: true, claim: semuta === 'claim' ? owner : null },
};
}
/** Apply the payer's own real declaration, its original Advanced Guild stop and
* any ordinary suffix. No receipt, leg or authority is ever supplied here. */
export function shipBankerOverlayIncome(fixture: BankerOverlayFixture): Game {
let game = applyAction(fixture.game, fixture.payer, fixture.shipment);
if (fixture.allow) {
assert.equal(game.decision?.kind, 'guildShipment',
'Advanced Shipment and Movement must open the original Guild stop first.');
assert.equal(game.decision?.player, fixture.allow.actor);
assert.equal(game.pendingShipment?.player, fixture.payer);
game = applyAction(game, fixture.allow.actor, fixture.allow.action);
}
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 {
const game = advanceBankerOverlay(state, candidate => !!candidate.pendingTreacheryDiscard,
{ hajr: true, claim: null });
const pending = game.pendingTreacheryDiscard;
assert.ok(pending, 'The conserved printed Hajr must produce one real fresh discard.');
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
assert.equal(pending.batch.cause, 'ordinary:hajr');
assert.equal(pending.batch.turn, game.turn);
assert.equal(pending.batch.phase, 5);
assert.deepEqual(pending.batch.entries.map(entry =>
({ id: entry.card.id, actor: entry.discardedBy, publicFace: entry.publicFace })),
[{ id: fixture.hajr, actor: fixture.holder, publicFace: true }]);
assert.equal(pending.reaction?.stage, 'offer',
'The original Semuta overlay must offer its neutral reaction before the printed continuation resumes.');
assert.ok(!bankerOverlayPlayer(game, fixture.holder).hand.some(card => card.id === fixture.hajr));
assert.equal(game.discard.filter(card => card.id === fixture.hajr).length, 1);
assert.deepEqual(game.hajr, [fixture.holder]);
return game;
}
/** Commit the owner's own printed Semuta at the open offer. The single freshly
* discarded card resolves the private selection atomically and resumes the
* original ordinary-card continuation without touching Banker income. */
export function claimBankerOverlaySemuta(state: Game, fixture: BankerOverlayFixture): Game {
const pending = state.pendingTreacheryDiscard;
assert.equal(pending?.reaction?.stage, 'offer', 'The printed Semuta offer must still be open.');
const own = viewGame(state, fixture.owner).semutaReaction;
assert.ok(own?.stage === 'offer' && own.canCommit, own?.blocked ?? 'The printed Semuta claim must be legally available.');
const inventory = bankerOverlayInventory(state), position = bankerOverlayPosition(state);
const ledger = bankerOverlayLedger(state, fixture.owner);
const game = applyAction(state, fixture.owner, { type: 'semutaCommit', event: pending!.batch.event });
assert.equal(game.pendingTreacheryDiscard, null,
'One fresh card resolves the private Semuta claim atomically.');
assert.equal(game.discard.filter(card => card.id === fixture.semuta).length, 1,
'The spent Semuta Drug must be discarded exactly once.');
assert.equal(bankerOverlayPlayer(game, fixture.owner).hand.filter(card => card.id === fixture.hajr).length, 1,
'The claimed fresh card must stay privately with its claimant.');
assert.deepEqual(bankerOverlayInventory(game), inventory);
assert.deepEqual(bankerOverlayPosition(game), position,
'The overlay continuation must not move wallets, counters, clocks or skills.');
assert.deepEqual(bankerOverlayLedger(game, fixture.owner), ledger,
'The overlay continuation must not add, move or duplicate Banker income.');
return game;
}
/** Decline the printed offer at every remaining seat and resume the same
* original continuation; no claim, income or refund is invented. */
export function passBankerOverlaySemuta(state: Game, fixture: BankerOverlayFixture): Game {
const inventory = bankerOverlayInventory(state);
const game = advanceBankerOverlay(state, candidate => !candidate.pendingTreacheryDiscard, fixture.plan);
assert.equal(game.discard.filter(card => card.id === fixture.semuta).length, 0);
assert.equal(bankerOverlayPlayer(game, fixture.owner).hand.filter(card => card.id === fixture.semuta).length, 1);
assert.equal(game.discard.filter(card => card.id === fixture.hajr).length, 1);
assert.deepEqual(bankerOverlayInventory(game), inventory);
assert.deepEqual(game.spiceBankerIncome, state.spiceBankerIncome,
'Declining the neutral overlay must not create Banker income.');
return game;
}
+210
View File
@@ -0,0 +1,210 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { nextSpiceBankerIncomeNativeStep, type SpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type BankerWorldStep = SpiceBankerIncomeNativeStep;
export type BankerWorldDefaultFixture = {
lobby: Game;
offered: Game;
setup: Game;
/** Control-ready original Guild turn; no invoice has been declared. */
game: Game;
owner: string;
payer: string;
trainer: string;
shipment: BankerWorldStep;
actions: BankerWorldStep[];
staging: string[];
};
export type BankerWorldMentatBoundary = {
beforeCollection: Game;
collectionAction: BankerWorldStep;
collection: Game;
/** Last real Collection acknowledgement, immediately before Mentat 1 opens. */
game: Game;
action: BankerWorldStep;
actions: BankerWorldStep[];
};
export const bankerWorldPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id);
assert.ok(player);
return player;
};
export const bankerWorldClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard;
export const bankerWorldWallets = (game: Game) =>
Object.fromEntries(game.players.map(p => [p.id, p.spice]));
/** Native no-bid/no-move policy. All owner-labelled windows remain real actions;
* no armies enter another faction's territory, so sealed battle plans are never
* manufactured. The original shared Banker stepper owns ordinary continuation. */
export function nextBankerWorldDefaultStep(game: Game): BankerWorldStep {
if (!game.phaseOpening && !game.response) {
const d = game.decision, event = game.richeseBidding?.event;
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: true } };
if (d?.kind === 'richeseBlackMarket') return { actor: d.player, action: { type: 'decision', event, decline: true } };
if (d?.kind === 'richeseDeclaration') return { actor: d.player, action: { type: 'decision', event, position: 'first' } };
if (d?.kind === 'richeseCache') {
assert.ok(game.richeseCache?.[0]);
return { actor: d.player, action: { type: 'decision', event, card: game.richeseCache[0].id,
method: 'onceAround', direction: 'counterclockwise' } };
}
if (d?.kind === 'richeseUnbid') return { actor: d.player, action: { type: 'decision', event, keep: false } };
if (game.nexusCards?.phase?.stage === 'drawing' && !d) {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
const lot = game.richeseAuction;
if (!d && lot && !lot.outcome) {
const actor = lot.method === 'silent'
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor);
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
}
}
assert.ok(!game.battle, 'This no-invasion programme must not invent a human Battle Plan.');
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return next;
}
export function advanceBankerWorldDefault(state: Game, until: (game: Game) => boolean,
actions: BankerWorldStep[] = []): Game {
let game = state;
for (let n = 0; n < 1400; n++) {
if (until(game)) return game;
assert.equal(game.status, 'playing');
const next = nextBankerWorldDefaultStep(game);
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
throw Error('Native World Banker continuation did not reach its requested control.');
}
/** Select only the original all14 shuffle before any offer is dealt. Subsequent
* native setup draws retain deterministic entropy without rewriting components. */
function withWorldOffers<T>(run: () => T): T {
const selected: LeaderSkillId[] = ['spice-banker', 'swordmaster-of-ginaz', 'warmaster'];
const cards = LEADER_SKILL_CARDS.map(c => c.id), rest = cards.filter(id => !selected.includes(id));
const desired = selected.flatMap(id => [id, rest.shift()!]).concat(rest);
const current = [...cards], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
[current[i], current[j]] = [current[j], current[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) {
for (let i = 0; i < array.length; i++) array[i] = rolls[index++] ?? 0xffffffff;
} else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Executable ordinary all14 World smoke source. Nexus/Discovery are original
* unused lobby selections; Tech and Advanced Strongholds use real host actions.
* Only closed original Spice Card order is staged, with its full multiset kept.
* No forces, balances, phase clocks, ownership or receipts are assigned. */
export function createBankerWorldDefaultFixture(): BankerWorldDefaultFixture {
let game = createGame('BANKERWORLDDEFAULT', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
game.nexusCards = { cards: null, phase: null };
const lobby = structuredClone(game), actions: BankerWorldStep[] = [];
for (const player of game.players) {
const next: BankerWorldStep = { actor: player.id, action: { type: 'ready' } };
actions.push(next); game = applyAction(game, next.actor, next.action);
}
game = withWorldOffers(() => initializeLeaderSkillsGameForAudit(game));
const offered = structuredClone(game);
const skills: LeaderSkillId[] = ['spice-banker', 'swordmaster-of-ginaz', 'warmaster'];
for (let n = 0; game.status === 'setup' && n < 200; n++) {
let next: BankerWorldStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = skills[game.players.findIndex(p => p.id === actor)];
assert.ok(offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && own.eligibleLeaders[0]);
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id } };
} else next = nextBankerWorldDefaultStep(game);
actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
const setup = structuredClone(game);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
// Two genuine ordinary blows close the first Advanced Spice phase without a
// worm/Nexus alliance or a Discovery entry changing this payment experiment.
for (let position = 0; position < 2; position++) {
const at = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(at >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
game = advanceBankerWorldDefault(game,
g => g.turn === 1 && g.phase === 5 && g.active === 'guild' && bankerWorldClean(g), actions);
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${t.id}:`))) &&
!Object.keys(game.spice).some(key => key.startsWith(`${t.id}:`) && game.spice[key] > 0));
assert.ok(destination);
const homeworldSources = nativeShipmentSources(viewGame(game, 'guild'), 4, 0);
assert.ok(homeworldSources);
const shipment: BankerWorldStep = { actor: 'guild', action: { type: 'ship', territory: destination.id,
sector: destination.sectors.find(s => s !== game.storm)!, amount: 4, elite: 0, allyPayment: 0, homeworldSources } };
const assignment = game.leaderSkills!.assignments.find(a => a.owner === 'richese' && a.skill === 'spice-banker');
assert.ok(assignment);
return { lobby, offered, setup, game, owner: 'richese', payer: 'guild', trainer: assignment.leader,
shipment, actions, staging: ['Original undealt all14 lottery; original unused Nexus/Discovery lobby selection; conserved first two closed ordinary Spice Cards.'] };
}
export function payBankerWorldDefault(fixture: BankerWorldDefaultFixture, action: Action = fixture.shipment.action): Game {
const paid = applyAction(fixture.game, fixture.shipment.actor, action);
return advanceBankerWorldDefault(paid, g => bankerWorldClean(g) && !g.pendingShipment);
}
/** Preserve the actual last Collection-ready action, not a synthetic phase
* write or an inferred wallet from a later turn containing unrelated income. */
export function bankerWorldBeforeMentat(state: Game): BankerWorldMentatBoundary {
const actions: BankerWorldStep[] = [];
let game = state, beforeCollection: Game | null = null, collectionAction: BankerWorldStep | null = null;
for (let n = 0; (game.phase !== 7 || !bankerWorldClean(game)) && n < 1000; n++) {
const before = game, next = nextBankerWorldDefaultStep(game);
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
if (before.phase !== 7 && game.phase === 7) {
beforeCollection = before;
collectionAction = next;
}
}
assert.ok(beforeCollection && collectionAction);
assert.equal(game.phase, 7);
assert.equal(game.turn, 1);
const collection = structuredClone(game);
for (let n = 0; n < game.players.length; n++) {
const next = nextBankerWorldDefaultStep(game);
assert.equal(next.action.type, 'ready');
if (game.ready.length === game.players.length - 1)
return { beforeCollection, collectionAction, collection, game, action: next, actions };
actions.push(next); game = applyAction(game, next.actor, next.action);
}
throw Error('Original Collection phase did not expose its final Mentat acknowledgement.');
}
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit,
applyAction, createGame, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Game, type Action,
} from '../game/engine';
import { validateLeaderSkills } from '../game/leader-skills';
@@ -45,7 +45,7 @@ export function createClassicNexusBankerFixture(options: {
if (strongholds) game.strongholdCards = createStrongholdCards();
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
const lobby = structuredClone(game);
game = withSpiceBankerIncomeSetupRandomness(() => initializeSpiceBankerIncomeGameForAudit(game));
game = withSpiceBankerIncomeSetupRandomness(() => initializeLeaderSkillsGameForAudit(game));
const started = structuredClone(game);
while (game.status === 'setup') {
if (game.setupStage === 'leaderSkills') {
+3 -6
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit,
initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer, viewGame,
joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
@@ -20,7 +20,6 @@ export type ClassicSkillsStrongholdOptions = {
initial?: Game;
seatIds?: string[];
optionalTech?: boolean;
bankerIncome?: boolean;
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
@@ -200,8 +199,7 @@ export function createClassicSkillsStrongholdFixture(options: ClassicSkillsStron
const owner = game.players.find(p => p.faction === (options.ownerFaction ?? 'guild'))?.id;
const opponent = game.players.find(p => p.faction === (options.opponentFaction ?? 'emperor'))?.id;
assert.ok(owner && opponent && owner !== opponent);
const banker = options.bankerIncome ? game.players.find(p => p.faction === 'atreides')?.id : undefined;
if (options.bankerIncome) assert.ok(banker && banker !== owner && banker !== opponent && game.players.length >= 3);
const banker = game.players.find(p => p.faction === 'atreides' && p.id !== owner && p.id !== opponent)?.id;
if (!game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
assert.deepEqual(game.strongholdCards, createStrongholdCards());
if (options.optionalTech) {
@@ -213,8 +211,7 @@ export function createClassicSkillsStrongholdFixture(options: ClassicSkillsStron
const passive: LeaderSkillId[] = (['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins'] as const)
.filter(candidate => candidate !== skill);
const selected = game.players.map(player => player.id === owner ? skill : player.id === banker ? 'spice-banker' as const : passive.shift()!);
game = withOffers(selected, () => options.bankerIncome
? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game));
game = withOffers(selected, () => initializeLeaderSkillsGameForAudit(game));
const initial = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 200; count++) {
if (game.setupStage === 'leaderSkills') {
+1 -1
View File
@@ -121,7 +121,7 @@ export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySki
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
const controlledOffers = game.status === 'lobby';
assert.deepEqual(game.expansions, []);
assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview);
assert.ok(!game.homeworlds && !game.nexusCards);
if (options.strongholdCards || game.strongholdCards) {
assert.equal(game.advanced, true);
if (!game.strongholdCards) {
+1 -1
View File
@@ -217,7 +217,7 @@ export function createDiscoveryNativeE2SkillsFixture(options: DiscoveryNativeE2S
joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.expansions, ['choam']);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview);
assert.ok(!game.homeworlds && !game.nexusCards);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.leaderSkills?.assignments.length === 0 && game.players.every(p =>
p.hand.length <= (p.faction === 'harkonnen' ? 2 : 1) && p.traitors.length === 0 && p.traitorChoices.length === 0),
+1 -1
View File
@@ -149,7 +149,7 @@ export function initializeDiscoveryNativeE3Skills(options: DiscoveryNativeE3Skil
assert.ok(game.players.every(p => ['moritani', 'guild', 'atreides', 'emperor', 'fremen', 'beneGesserit',
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
assert.ok(!strongholds || advanced);
assert.ok(!game.nexusCards && !game.homeworlds && !game.ecazTreachery && !game.spiceBankerIncomePreview);
assert.ok(!game.nexusCards && !game.homeworlds && !game.ecazTreachery);
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
@@ -116,8 +116,7 @@ export function initializeDiscoveryStandaloneNexusE3Skills(options: DiscoverySta
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
assert.ok(advanced || native !== 'ecaz' || game.players.length % 2 === 0, 'Basic odd-seat Ecaz remains source guarded.');
assert.ok(!strongholds || advanced);
assert.ok(!game.homeworlds && !game.ecazTreachery && !game.advancedPreview && !game.semutaPreview &&
!game.spiceBankerIncomePreview);
assert.ok(!game.homeworlds && !game.ecazTreachery && !game.advancedPreview && !game.semutaPreview);
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
+1 -3
View File
@@ -22,8 +22,6 @@ export type EcazSkillsModulesOptions = {
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
strongholdKind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
/** Separate existing native Banker income opt-in, not a skill-module default. */
bankerIncome?: boolean;
poison?: boolean;
defense?: boolean;
opponentDial?: number;
@@ -147,7 +145,7 @@ export function createEcazSkillsModulesFixture(options: EcazSkillsModulesOptions
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== stronghold) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: stronghold });
const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', rules, requestedSkill: requested, bankerIncome: options.bankerIncome, initial: game });
game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', rules, requestedSkill: requested, initial: game });
const offered = structuredClone(game);
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.setupStage === 'leaderSkills') {
+4 -5
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer,
applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
@@ -102,9 +102,8 @@ export const homeworldBankerSector = (game: Game, target: string): number => {
};
/** Genuine ordinary reserve shipment out of a faction's own Homeworld reserves
* into an empty printed board location. The Banker profile allows only the
* original native payment fields, so the withdrawal keeps its default own-home
* allocation and no force, wallet, phase or receipt was assigned. */
* into an empty printed board location. The withdrawal keeps its default
* own-home allocation; no force, wallet, phase or receipt is assigned. */
export function homeworldBankerShipment(game: Game, actor: string, target: string,
amount: number, allyPayment = 0): Action {
assert.ok(game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${target}:`))),
@@ -201,7 +200,7 @@ function admitHomeworldBankerGame(): Game {
const canonical = LEADER_SKILL_CARDS.map(card => card.id);
const rest = canonical.filter(id => !REQUESTED_SKILLS.includes(id));
const desired = REQUESTED_SKILLS.flatMap(id => [id, rest.shift()!]).concat(rest);
return originalLottery(permutationDraws(canonical, desired), () => initializeSpiceBankerIncomeGameForAudit(game));
return originalLottery(permutationDraws(canonical, desired), () => initializeHomeworldOccupationGameForAudit(game, true));
}
/** Genuine setup selections: each requested skill is chosen from its real
+6 -10
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit,
applyAction, createGame, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
@@ -19,8 +19,6 @@ export type IxChoamSkillsTechOptions = {
amount?: number;
elite?: number;
smuggler?: boolean;
/** Separate normal-income audit opt-in; needs an actual Atreides seat. */
bankerIncome?: boolean;
};
export type IxChoamSkillsTechStep = { actor: string; action: Action };
export type IxChoamSkillsTechFixture = {
@@ -108,7 +106,7 @@ export function initializeIxChoamSkillsTechSetup(options: IxChoamSkillsTechOptio
if (!options.initial) {
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('fremen', 'Fremen', 'fremen'));
if (options.bankerIncome) joinGame(game, newPlayer('banker', 'Banker', 'atreides'));
joinGame(game, newPlayer('banker', 'Banker', 'atreides'));
}
assert.equal(game.status, 'lobby', 'Only the original fresh undealt lobby is accepted.');
assert.ok(game.expansions.includes(expansion));
@@ -116,16 +114,14 @@ export function initializeIxChoamSkillsTechSetup(options: IxChoamSkillsTechOptio
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.players.some(p => p.faction === family));
assert.ok(game.players.some(p => p.faction === 'emperor'));
if (options.bankerIncome) assert.ok(game.players.some(p => p.faction === 'atreides'));
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
assert.deepEqual(game.techTokens, createTechTokens());
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
const passive: LeaderSkillId[] = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'planetologist'];
const selected = game.players.map(p => p.faction === family
? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler'
: options.bankerIncome && p.faction === 'atreides' ? 'spice-banker' : passive.shift()!);
return withOffers(selected, () => options.bankerIncome
? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game));
: p.faction === 'atreides' ? 'spice-banker' : passive.shift()!);
return withOffers(selected, () => initializeLeaderSkillsGameForAudit(game));
}
export function completeIxChoamSkillsTechSetup(state: Game, options: IxChoamSkillsTechOptions = {}, actions?: IxChoamSkillsTechStep[]): Game {
@@ -138,7 +134,7 @@ export function completeIxChoamSkillsTechSetup(state: Game, options: IxChoamSkil
const faction = ixChoamSkillsTechPlayer(game, actor).faction;
const skill = faction === (options.family ?? 'ixians')
? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler'
: options.bankerIncome && faction === 'atreides' ? 'spice-banker' : offer.cards[0];
: faction === 'atreides' ? 'spice-banker' : offer.cards[0];
assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = ixChoamSkillsTechPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
@@ -166,7 +162,7 @@ export function createIxChoamSkillsTechFixture(options: IxChoamSkillsTechOptions
let game = completeIxChoamSkillsTechSetup(setup, options, actions);
const owner = game.players.find(p => p.faction === family)!.id;
const opponent = game.players.find(p => p.faction === 'emperor')!.id;
const banker = options.bankerIncome ? game.players.find(p => p.faction === 'atreides')!.id : undefined;
const banker = game.players.find(p => p.faction === 'atreides')?.id;
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
// Four conserved non-worm cards avoid optional riders during the two-turn
// charity continuation. They are not asserted to be a natural shuffle history.
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit,
applyAction, createGame, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
@@ -78,7 +78,7 @@ export function initializeNativeBankerIncomeSetup(options: NativeBankerIncomeOpt
assert.equal(game.status, 'lobby');
} else for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withNativeBankerSkillShuffle(() => initializeSpiceBankerIncomeGameForAudit(game));
return withNativeBankerSkillShuffle(() => initializeLeaderSkillsGameForAudit(game));
}
function step(game: Game): Game {
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer,
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
@@ -185,7 +185,7 @@ export function createRicheseSkillsTechFixture(options: RicheseSkillsTechOptions
const safe: LeaderSkillId[] = ['prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster', 'killer-medic'];
const selected = game.players.map(p => p.id === owner ? skill : p.id === banker ? 'spice-banker' as const : safe.shift()!);
const bySeat: Record<string, LeaderSkillId> = Object.fromEntries(game.players.map((p, i) => [p.id, selected[i]]));
game = withOffers(selected, () => initializeSpiceBankerIncomeGameForAudit(game));
game = withOffers(selected, () => initializeLeaderSkillsGameForAudit(game));
const setup = structuredClone(game);
for (let n = 0; game.status === 'setup' && n < 200; n++) {
if (game.setupStage === 'leaderSkills') {
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit,
applyAction, createGame, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
@@ -107,7 +107,7 @@ export function initializeSkillsTechPaymentsSetup(options: SkillsTechPaymentsOpt
// setup/first Storm alone assign ownership; never retrofit a played game.
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withPaymentSkillOffers(game, () => initializeSpiceBankerIncomeGameForAudit(game));
return withPaymentSkillOffers(game, () => initializeLeaderSkillsGameForAudit(game));
}
export function completeSkillsTechPaymentsSetup(state: Game, actions?: SkillsTechPaymentsStep[]): Game {
+2 -2
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import {applyAction,createGame,handLimit,initializeSpiceBankerIncomeGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine';
import {applyAction,createGame,handLimit,initializeLeaderSkillsGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine';
import {botActions} from '../game/bots';
import {TERRITORIES} from '../game/board';
import {LEADER_SKILL_CARDS} from '../game/leader-skill-cards';
@@ -80,7 +80,7 @@ function setup(initial:Game):Game {
let game=structuredClone(initial);
if(game.status==='lobby') {
for(const p of game.players) if(!p.ready) game=applyAction(game,p.id,{type:'ready'});
game=withSpiceBankerIncomeSetupRandomness(()=>initializeSpiceBankerIncomeGameForAudit(game));
game=withSpiceBankerIncomeSetupRandomness(()=>initializeLeaderSkillsGameForAudit(game));
} else assert.ok(game.status==='setup'&&game.spiceBankerIncomePreview&&game.turn===1&&game.phase===0,'Only the original fresh admitted setup can continue.');
for(let i=0;game.status==='setup'&&i<200;i++) {
if(game.setupStage==='leaderSkills') {
+2 -2
View File
@@ -33,7 +33,7 @@ for (const advanced of [false, true]) {
const rules = advanced ? 'Advanced' : 'Basic';
for (const family of ['ixians', 'choam'] as const) {
for (const smuggler of [true, false]) void test(`${rules} ${family}: real typed four-counter invoice ${smuggler ? 'includes the free companion' : 'declines it'} and pays only native Heighliners at phase end`, () => {
const fixture = createIxChoamSkillsTechFixture({ advanced, family, amount: 4, smuggler, bankerIncome: true });
const fixture = createIxChoamSkillsTechFixture({ advanced, family, amount: 4, smuggler });
const before = fixture.game;
const payer = player(before, fixture.actor);
const tokenOwner = before.techTokens!.heighliners.owner!;
@@ -144,7 +144,7 @@ for (const advanced of [false, true]) {
}
void test(`${rules} CHOAM: real exhausted Emperor shipment makes next-turn charity eligible; only original Spice Production accrues, never Axlotl`, () => {
const fixture = createIxChoamSkillsTechFixture({ advanced, family: 'choam', kind: 'charity', bankerIncome: true });
const fixture = createIxChoamSkillsTechFixture({ advanced, family: 'choam', kind: 'charity' });
const before = fixture.game;
assert.ok(fixture.actions.some(step => step.actor === fixture.actor && step.action.type === 'ship'
&& step.action.amount === 5), 'Eligibility comes from an actual original ten-spice desert invoice.');
@@ -148,9 +148,6 @@ for (const config of configurations) {
assert.equal(receipt.beforeSpice, buyer.spice); assert.equal(receipt.afterSpice, buyer.spice);
assert.equal(paid.nexusEmperorPurchaseHistory!.length, 1);
assert.deepEqual(paid.techTokens, f.game.techTokens);
assert.equal(viewGame(paid, f.actor).spiceBankerIncome, null);
assert.equal(paid.spiceBankerIncome, undefined);
assert.equal(paid.currentAuctionSale?.spiceBankerIncomePayment, undefined);
custody(f.game, paid); rejected(paid, f.actor, f.paymentAction);
const continued = advanceNexusSkillsModulesPaymentsToPhase(paid, 4);
assert.equal(nexusSkillsModulesPaymentsPlayer(continued, f.actor).hand.filter(card => card.id === lot.id).length, 1);
+5 -63
View File
@@ -810,71 +810,13 @@ void test('Harkonnen Betrayal CLI backs up every room and seat privately before
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
});
for (const advanced of [false, true]) {
for (const seats of [2, 3, 4, 5, 6]) {
void test(`Banker income ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry conserves the original base and skill inventories without changing credentials`, t => {
const { db } = fixture(t);
const lobby = harkonnenLobby(advanced, seats);
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(lobby), lobby.code);
const credentials = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'banker-income'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
startPrototypeRoom(db, lobby.code, 7, 'banker-income');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(lobby.code)!.state as string) as Game;
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.spiceBankerIncomePreview, true);
assert.equal(saved.advanced, advanced);
assert.equal(saved.host, lobby.host);
assert.deepEqual(saved.playerPositions, lobby.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
const stock = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand)];
assert.equal(stock.length, 33);
assert.equal(new Set(stock.map(card => card.id)).size, 33);
assert.ok(saved.leaderSkills);
const skills = [...saved.leaderSkills.deck, ...Object.values(saved.leaderSkills.offers).flatMap(offer => offer.cards),
...saved.leaderSkills.assignments.map(assignment => assignment.skill)];
assert.deepEqual(skills.sort(), createLeaderSkills(() => 0.5).deck.sort());
for (const player of saved.players) {
const view = viewGame(saved, player.id);
assert.deepEqual(view.spiceBankerIncome, { deferred: [], usedThisPhase: [] });
assert.equal(view.spiceBankerIncomePreview, true);
}
const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
for (const profile of ['banker-income', 'leader-skills', 'nexus'] as const)
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, profile));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
}
void test('Banker income rejects incompatible profiles and public activation without modifying any saved room or credential', t => {
void test('Leader Skills rejects invalid or already-dealt lobbies and forged public activation without changing saved rooms or credentials', t => {
const { db } = fixture(t);
const native = harkonnenLobby();
const changes: ((game: Game) => void)[] = [
game => { game.players[0].ready = false; },
game => { game.code = 'NOTMATCH'; },
game => { game.players[1].faction = game.players[0].faction; },
...(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const).map(faction =>
(game: Game) => { game.players[1].faction = faction; }),
...(['ix', 'choam', 'ecaz'] as const).map(expansion =>
(game: Game) => { game.expansions = [expansion]; }),
game => { game.discoveryEnabled = true; },
game => { game.ecazTreachery = true; },
game => { game.semutaPreview = true; },
game => { game.nexusIxianReplacementPreview = true; },
game => { game.nexusIxianBetrayalPreview = true; },
game => { game.nexusHarkonnenBetrayalPreview = true; },
game => { game.kullPreview = true; },
game => { game.nexusKullPreview = true; },
game => { game.guildBetrayalPreview = true; },
game => { game.richeseBetrayalPreview = true; },
game => { game.moritaniAssassinatePreview = true; },
game => { game.spiceBankerIncomePreview = true; },
game => { game.leaderSkills = createLeaderSkills(() => 0.5); },
game => { game.players.splice(1); },
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
@@ -887,7 +829,7 @@ void test('Banker income rejects incompatible profiles and public activation wit
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats').all();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'banker-income'));
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'leader-skills'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
}
@@ -898,8 +840,8 @@ void test('Banker income rejects incompatible profiles and public activation wit
assert.deepEqual(native, original);
});
void test('Banker income CLI requires a fresh exact-version private backup and preserves native setup, old rooms and credentials', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-banker-income-'));
void test('Leader Skills CLI requires a fresh exact-version private backup and preserves native setup, old rooms and credentials', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-leader-skills-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
@@ -918,7 +860,7 @@ void test('Banker income CLI requires a fresh exact-version private backup and p
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'banker-income', '--db', database, '--room', lobby.code,
'--profile', 'leader-skills', '--db', database, '--room', lobby.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const result = invoke(7, output);
+5 -3
View File
@@ -37,7 +37,7 @@ function prepare(options: Options = {}): NativeRichesePaymentFixture {
let game = initializeAdvancedNativeSkillsSetup({
family: 'richese', rules: options.rules ?? 'advanced', opponents,
requestedSkill: options.banker ? 'spice-banker' : 'suk-graduate',
skillOwner: options.banker ? 'beneGesserit' : 'richese', bankerIncome: options.banker,
skillOwner: options.banker ? 'beneGesserit' : 'richese',
});
const skillOwner = advancedNativePlayer(game, options.banker ? 'beneGesserit' : 'richese').id;
const requested = options.banker ? 'spice-banker' : 'suk-graduate';
@@ -72,7 +72,9 @@ function prepare(options: Options = {}): NativeRichesePaymentFixture {
else {
const holder = game.players.find(p => p.id !== observer && p.hand.some(c => c.effect === 'karama'))!;
assert.ok(holder, 'Transfer an existing original physical Karama, never manufacture one.');
card = holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1)[0];
const returned = owner.hand.pop();
assert.ok(returned, 'Exchange an original held card so donor auction eligibility is preserved.');
card = holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1, returned)[0];
}
owner.hand.push(card);
}
@@ -187,7 +189,7 @@ for (const rules of ['basic', 'advanced'] as const) {
}
for (const source of ['cache', 'blackMarket'] as const) {
void test(`Advanced native ${source}: only Richese bank-routed contribution qualifies separate Banker, deferred until native Mentat`, () => {
void test(`Advanced native ${source}: only Richese bank-routed contribution qualifies Banker, deferred until native Mentat`, () => {
const f = prepare({ source, emperor: false, banker: true });
const ownerBalance = balance(f.game, f.observer);
let game = finishResponses(purchase(f));
+1 -1
View File
@@ -90,7 +90,7 @@ async function fixture(t: test.TestContext, options: Options = {}): Promise<Fixt
}
// BG keeps the entry's actual native setup undealt. The fixture consumes that
// original setup; it cannot reset already dealt cards or replace an offer.
withSpiceBankerIncomeSetupRandomness(() => startPrototypeRoom(store.sqlite, code, lobby.version, 'banker-income'));
withSpiceBankerIncomeSetupRandomness(() => startPrototypeRoom(store.sqlite, code, lobby.version, 'leader-skills'));
const setup = await store.rooms.readRoom(code);
const native = createSpiceBankerIncomeFixture({ ...options, initial: setup });
assert.equal(native.beforePayment.code, code);