Make normal Banker income mandatory in fresh Skills setups
This commit is contained in:
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user