Admit original native Nexus bank purchases without widening legacy revivals

This commit is contained in:
Quintine committed 2026-10-15 11:41:20 +11:00
1 parent e2dc5f3d0d
commit 172363d9d3
14 files changed
+681 -39

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { validateNexusCards } from '../game/nexus-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { emperorNexusPurchaseEvent } from '../game/nexus-emperor-purchase';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { createSingleNexusE1Setup, nextSingleNexusE1Step } from './fixture-single-nexus-e1';
import { createSingleE2Setup, nextSingleE2Step } from './fixture-single-nexus-e2';
import { createE3NexusEcazSetup, nextE3NexusEcazStep, orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz';
import { createMoritaniNexusSetup, nextMoritaniNexusStep } from './fixture-e3-nexus-moritani';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type NexusEmperorBankOptions = {
advanced?: boolean;
profile?: 'ixians' | 'tleilaxu' | 'richese' | 'choam' | 'ecaz' | 'moritani';
nexusCard?: 'emperor' | 'guild';
homeworlds?: boolean;
tech?: boolean;
};
export type NexusEmperorBankStep = { actor: string; action: Action };
export type NexusEmperorBankTransition = { before: Game; step: NexusEmperorBankStep; after: Game };
export type NexusEmperorBankFixture = {
game: Game; owner: string; event: string; card: Card; price: number;
wallets: Record<string, number>; actions: NexusEmperorBankStep[];
chronology: {
initial: Game; afterSetup: Game; firstMentat: NexusEmperorBankTransition;
worm: Game; alliance: Game; draw: NexusEmperorBankTransition;
afterClosingDraw: Game; beforeBid: Game;
};
};
export function nexusEmperorBankPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision &&
!g.truthtrance && !g.pendingTreacheryDiscard;
export function stepNexusEmperorBank(game: Game, step: NexusEmperorBankStep,
actions?: NexusEmperorBankStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(structuredClone(game), step.actor, step.action));
}
/** Pass original optional windows and cache bids; never manufacture a plan or acquire cache fuel. */
export function nextNexusEmperorBankStep(game: Game): NexusEmperorBankStep {
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
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] ?? null, choice: 'keep', ownRedraws: 0 } };
}
if (!game.phaseOpening && !game.response && !game.truthtrance) {
if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
if (game.decision?.kind === 'techToken') return { actor: game.decision.player,
action: { type: 'decision', token: game.decision.choices[0] } };
if (!game.decision && game.richeseAuction) {
const lot = game.richeseAuction;
const actor = lot.method === 'silent'
? lot.eligible.find(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, allyPayment: 0 } };
}
}
const next = game.players.some(p => p.faction === 'moritani') ? nextMoritaniNexusStep(game)
: game.players.some(p => p.faction === 'ecaz') ? nextE3NexusEcazStep(game)
: game.expansions.includes('choam') ? nextSingleE2Step(game)
: game.expansions.includes('ix') ? nextSingleNexusE1Step(game)
: nextStrongholdFactionsNativeStep(game);
assert.ok(next, `Original BANK chronology needs its actual ${game.decision?.kind ?? 'sealed plan'} owner`);
return next;
}
export function advanceNexusEmperorBank(game: Game, until: (game: Game) => boolean,
actions?: NexusEmperorBankStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = stepNexusEmperorBank(game, nextNexusEmperorBankStep(game), actions);
}
throw Error('Original BANK chronology did not reach its requested boundary');
}
/** Paid auction prefixes are references, not a second physical card zone. */
export function nexusEmperorBankInventory(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(game.expansions),
...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
validateNexusCards(game.nexusCards!.cards!, game.players);
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors);
if (game.homeworlds) homeworldGameIntegrity(game);
const homes = game.homeworlds ? homeworldForceGroups(homeworldContext(game), game.homeworlds.custody!) : [];
for (const player of game.players) {
const visitors = homes.filter(h => h.native !== player.id).map(h => h.forces[player.id] ?? { normal: 0, elite: 0 });
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((n, f) => n + f, 0) +
visitors.reduce((n, f) => n + f.normal + f.elite, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((n, f) => n + f, 0) + visitors.reduce((n, f) => n + f.elite, 0),
player.faction === 'ixians' ? 7 : player.faction === 'emperor' ? 5 : 3);
}
}
/** Genuine native producer, full first turn, other-seat pact and physical closing draw.
* Only still-undealt Spice/Nexus (and classic starting Treachery) order is selected. */
export function createNexusEmperorBankFixture(options: NexusEmperorBankOptions = {}): NexusEmperorBankFixture {
const advanced = options.advanced ?? false, profile = options.profile;
const roster = [profile ?? 'harkonnen', 'guild', 'atreides'] as const;
const modules = { advanced, roster, homeworlds: options.homeworlds ?? false, tech: options.tech ?? false };
let game: Game, initial: Game, afterSetup: Game;
const actions: NexusEmperorBankStep[] = [];
if (profile === 'ixians' || profile === 'tleilaxu') {
const setup = createSingleNexusE1Setup({ ...modules, native: profile, skills: false,
startingEffects: [undefined, undefined, 'truthtrance'] });
({ initial, afterSetup } = setup); actions.push(...setup.actions); game = structuredClone(afterSetup);
} else if (profile === 'richese' || profile === 'choam') {
const setup = createSingleE2Setup({ ...modules, native: profile, skills: false });
({ initial, afterSetup } = setup); actions.push(...setup.actions); game = structuredClone(afterSetup);
} else if (profile === 'ecaz' || profile === 'moritani') {
const setup = profile === 'ecaz' ? createE3NexusEcazSetup(modules) : createMoritaniNexusSetup(modules);
({ initial, afterSetup } = setup); actions.push(...setup.actions); game = structuredClone(afterSetup);
} else {
game = createGame('NEXUSEMPERORBANK', newPlayer(roster[0], roster[0], roster[0]), advanced);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
game.nexusCards = { cards: null, phase: null };
for (const [type, enabled] of [['homeworlds', modules.homeworlds], ['techTokens', modules.tech]] as const)
if (enabled) game = stepNexusEmperorBank(game, { actor: game.host, action: { type, enabled } }, actions);
initial = structuredClone(game);
for (const player of game.players) game = stepNexusEmperorBank(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game));
const prefix: Card[] = [];
for (let n = 0; n < 4; n++) {
const index = game.deck.findIndex(c => c.kind === 'worthless'); assert.ok(index >= 0);
prefix.push(game.deck.splice(index, 1)[0]);
}
game.deck.unshift(...prefix);
game = advanceNexusEmperorBank(game, g => g.status === 'playing', actions);
afterSetup = structuredClone(game);
}
const owner = roster[0];
orderE3NexusEcazSpice(game);
let firstMentat: NexusEmperorBankTransition | undefined;
for (let n = 0; game.turn === 1 && n < 600; n++) {
const before = structuredClone(game), step = nextNexusEmperorBankStep(game);
game = stepNexusEmperorBank(game, step, actions);
if (game.turn !== before.turn) firstMentat = { before, step, after: structuredClone(game) };
}
assert.ok(firstMentat);
game = advanceNexusEmperorBank(game, g => g.phase === 1 && clean(g), actions);
orderE3NexusEcazSpice(game, true);
game = advanceNexusEmperorBank(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), actions);
const worm = structuredClone(game);
for (const [actor, target] of [['guild', 'atreides'], ['atreides', 'guild']])
game = stepNexusEmperorBank(game, { actor, action: { type: 'alliance', target } }, actions);
const alliance = structuredClone(game);
game = advanceNexusEmperorBank(game, g => g.nexusCards?.phase?.stage === 'drawing' && clean(g), actions);
const cards = game.nexusCards!.cards!, face = options.nexusCard ?? 'emperor';
const index = cards.deck.indexOf(face); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const before = structuredClone(game), step: NexusEmperorBankStep = { actor: owner,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[owner] ?? null, choice: 'draw', ownRedraws: 0 } };
game = stepNexusEmperorBank(game, step, actions);
const draw = { before, step, after: structuredClone(game) };
assert.equal(game.nexusCards!.cards!.hands[owner], face);
game = advanceNexusEmperorBank(game, g => g.nexusCards?.phase?.stage === 'complete' && clean(g), actions);
const afterClosingDraw = structuredClone(game);
game = advanceNexusEmperorBank(game, g => g.phase === 3 && !!g.auction && !g.richeseAuction && clean(g), actions);
while (game.auction!.active !== owner)
game = stepNexusEmperorBank(game, { actor: game.auction!.active, action: { type: 'passBid' } }, actions);
const beforeBid = structuredClone(game), card = structuredClone(game.auction!.cards[game.auction!.index]);
const price = 2;
game = stepNexusEmperorBank(game, { actor: owner, action: { type: 'bid', amount: price, allyPayment: 0 } }, actions);
game = advanceNexusEmperorBank(game, g => g.decision?.kind === 'auctionPayment', actions);
assert.equal(game.decision!.player, owner);
assert.equal(game.auction!.bid, price);
assert.equal(game.auction!.cards[game.auction!.index].id, card.id);
assert.equal(nexusEmperorBankPlayer(game, owner).hand.some(c => c.id === card.id), false);
const event = emperorNexusPurchaseEvent(game.turn, owner,
game.nexusEmperorPurchaseHistory?.length ?? 0, game.auction!.index, price);
nexusEmperorBankInventory(game);
return { game, owner, event, card, price, wallets: Object.fromEntries(game.players.map(p => [p.id, p.spice])), actions,
chronology: { initial, afterSetup, firstMentat, worm, alliance, draw, afterClosingDraw, beforeBid } };
}
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, type Game } from '../game/engine';
import { emperorNexusBankPurchaseModeSupported } from '../game/nexus-emperor-purchase';
/** Public lobby proof only: an undealt, concealed Nexus face is not yet part of it. */
function lobby(
factions: Parameters<typeof newPlayer>[2][],
expansions: string[],
advanced = false,
): Game {
const g = createGame('BANKMODE', newPlayer(factions[0], factions[0], factions[0]), advanced, expansions);
for (const faction of factions.slice(1)) g.players.push(newPlayer(faction, faction, faction));
g.order = g.players.map(p => p.id);
g.nexusCards = { cards: null, phase: null };
return g;
}
function withTech(g: Game): Game {
g.techTokens = {} as NonNullable<Game['techTokens']>;
return g;
}
function withStrongholds(g: Game): Game {
g.strongholdCards = {} as NonNullable<Game['strongholdCards']>;
return g;
}
function withSkills(g: Game): Game {
g.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
return g;
}
function withDiscovery(g: Game): Game {
g.discoveryEnabled = true;
return g;
}
function withHomeworlds(g: Game): Game {
g.homeworlds = { custody: null };
return g;
}
void test('supported native Nexus rosters with the Emperor absent admit the normal bank auction', () => {
const admitted: [string, Game][] = [
['bare classic', lobby(['atreides', 'harkonnen', 'fremen'], [])],
['classic Tech', withTech(lobby(['atreides', 'harkonnen', 'fremen'], []))],
['classic Advanced Strongholds', withStrongholds(lobby(['atreides', 'harkonnen', 'fremen'], [], true))],
['classic Skills', withSkills(lobby(['atreides', 'harkonnen', 'fremen'], []))],
['classic Discovery', withDiscovery(lobby(['atreides', 'harkonnen', 'fremen'], []))],
['bare paired CHOAM/Richese', lobby(['harkonnen', 'richese', 'choam'], ['choam'])],
['bare paired Ixians/Tleilaxu', lobby(['atreides', 'ixians', 'tleilaxu'], ['ix'])],
['paired E1/E2 Tech', withTech(lobby(['harkonnen', 'richese', 'choam'], ['choam']))],
['paired E1/E2 Advanced Strongholds', withStrongholds(lobby(['harkonnen', 'richese', 'choam'], ['choam'], true))],
['paired E1/E2 Skills with Tech', withTech(withSkills(lobby(['harkonnen', 'richese', 'choam'], ['choam'])))],
['paired E1/E2 Homeworlds', withHomeworlds(withTech(lobby(['harkonnen', 'richese', 'choam'], ['choam'])))],
['paired E1/E2 Discovery', withDiscovery(lobby(['harkonnen', 'richese', 'choam'], ['choam']))],
['single E1/E2 native', lobby(['harkonnen', 'richese', 'atreides'], ['choam'])],
['single E1/E2 Skills', withSkills(lobby(['harkonnen', 'richese', 'atreides'], ['choam']))],
['mixed E1/E2 natives', lobby(['ixians', 'choam', 'atreides'], ['ix', 'choam'])],
['mixed E1/E2 Skills', withSkills(lobby(['ixians', 'choam', 'atreides'], ['ix', 'choam']))],
['standalone Ecaz', lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz'])],
['standalone Moritani', lobby(['moritani', 'atreides', 'guild'], ['ecaz'])],
['standalone E3 Skills', withSkills(lobby(['moritani', 'atreides', 'guild'], ['ecaz']))],
['paired E3 natives', lobby(['ecaz', 'moritani', 'atreides'], ['ecaz'])],
['paired E3 Skills', withSkills(lobby(['ecaz', 'moritani', 'atreides'], ['ecaz']))],
['mixed E3 natives', lobby(['ecaz', 'ixians', 'atreides'], ['ecaz', 'ix'])],
['mixed E3 Skills', withSkills(lobby(['ecaz', 'ixians', 'atreides'], ['ecaz', 'ix']))],
['mixed E3 Discovery', withDiscovery(lobby(['ecaz', 'ixians', 'atreides'], ['ecaz', 'ix']))],
];
for (const [label, g] of admitted)
assert.equal(emperorNexusBankPurchaseModeSupported(g), true, label);
});
void test('the previously accepted classic public condition survives unrelated preview aliases', () => {
const aliases: [string, (g: Game) => void][] = [
['Advanced preview', g => { g.advancedPreview = true; }],
['Kull', g => { g.kullPreview = true; }],
['Nexus Kull', g => { g.nexusKullPreview = true; }],
['Guild Betrayal', g => { g.guildBetrayalPreview = true; }],
['Richese Betrayal', g => { g.richeseBetrayalPreview = true; }],
['Semuta', g => { g.semutaPreview = true; }],
['Ixian replacement', g => { g.nexusIxianReplacementPreview = true; }],
['Ixian Betrayal', g => { g.nexusIxianBetrayalPreview = true; }],
['Harkonnen Betrayal', g => { g.nexusHarkonnenBetrayalPreview = true; }],
];
for (const [label, flag] of aliases) {
const classic = lobby(['atreides', 'harkonnen', 'fremen'], []);
flag(classic);
// Prior bank receipts on a bare classic roster keep their original condition.
assert.equal(emperorNexusBankPurchaseModeSupported(classic), true, label);
// The same flag still never opens a new native envelope, even bare paired.
const native = withTech(lobby(['harkonnen', 'richese', 'choam'], ['choam']));
flag(native);
assert.equal(emperorNexusBankPurchaseModeSupported(native), false, label);
}
});
void test('Ecaz variant, the missing module and the seated Emperor stay gated', () => {
const withoutNexus = lobby(['atreides', 'harkonnen', 'fremen'], []);
withoutNexus.nexusCards = undefined;
const sandtrout = lobby(['atreides', 'harkonnen', 'fremen'], []);
sandtrout.sandtrout = true;
const ecazTreachery = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
ecazTreachery.ecazTreachery = true;
const blocked: [string, Game][] = [
['a missing Nexus module', withoutNexus],
['Sandtrout', sandtrout],
['Ecaz Treachery', ecazTreachery],
['an Emperor seat', lobby(['atreides', 'harkonnen', 'emperor'], [])],
['an Emperor seat on a native roster', lobby(['harkonnen', 'richese', 'emperor'], ['choam'])],
];
for (const [label, g] of blocked)
assert.equal(emperorNexusBankPurchaseModeSupported(g), false, label);
});
void test('unsupported rosters and unsupported module envelopes stay gated', () => {
const blocked: [string, Game][] = [
['an Ecaz seat on a classic roster', lobby(['atreides', 'ecaz'], [])],
['a native without its expansion', lobby(['atreides', 'richese'], [])],
['an E3 expansion without its natives', lobby(['atreides', 'harkonnen'], ['ecaz'])],
['a native from the other E1/E2 family', lobby(['richese', 'ixians', 'atreides'], ['choam'])],
['a repeated native faction', lobby(['richese', 'richese', 'choam'], ['choam'])],
['Tech with only two seats', withTech(lobby(['atreides', 'harkonnen'], []))],
['Strongholds without Advanced', withStrongholds(lobby(['atreides', 'harkonnen', 'fremen'], []))],
['Advanced Moritani with Harkonnen',
lobby(['moritani', 'atreides', 'harkonnen'], ['ecaz'], true)],
['Advanced paired E3 with Harkonnen',
lobby(['ecaz', 'moritani', 'harkonnen'], ['ecaz'], true)],
['Skills on an E3 classic mix', withSkills(lobby(['ecaz', 'atreides'], []))],
];
for (const [label, g] of blocked)
assert.equal(emperorNexusBankPurchaseModeSupported(g), false, label);
});
+160
View File
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
advanceNexusEmperorBank, createNexusEmperorBankFixture, nexusEmperorBankInventory,
nexusEmperorBankPlayer, nextNexusEmperorBankStep, stepNexusEmperorBank,
type NexusEmperorBankFixture, type NexusEmperorBankOptions,
} from './fixture-nexus-emperor-bank';
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
const original = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, original, 'Reject before any wallet, card or native lot mutation');
}
/** Consume actual original sale/bonus and the next original inspection, not a new auction frame. */
function finishOriginalLot(f: NexusEmperorBankFixture, game: Game): Game {
const index = f.game.auction!.index;
for (let n = 0; n < 100; n++) {
if (game.auction && game.auction.index === index + 1 &&
!game.currentAuctionSale && !game.response && !game.phaseOpening && !game.decision) {
assert.deepEqual(viewGame(game, 'atreides').auction!.card, game.auction.cards[game.auction.index]);
assert.equal(nexusEmperorBankPlayer(game, f.owner).hand.length,
nexusEmperorBankPlayer(f.game, f.owner).hand.length + 1 +
Number(nexusEmperorBankPlayer(game, f.owner).faction === 'harkonnen'));
assert.equal(nexusEmperorBankPlayer(game, f.owner).hand.filter(c => c.id === f.card.id).length, 1);
return game;
}
assert.notEqual(game.response?.kind, 'emperorIncome', 'No bank-funded replacement income');
assert.notEqual(game.decision?.kind, 'auctionPayment', 'The acquired lot cannot ask for payment twice');
game = stepNexusEmperorBank(game, nextNexusEmperorBankStep(game));
}
throw Error('Original BANK lot did not reach its native next lot');
}
const natives = ['ixians', 'tleilaxu', 'richese', 'choam', 'ecaz', 'moritani'] as const;
const scenarios: NexusEmperorBankOptions[] = [
{ advanced: false }, { advanced: true },
...natives.flatMap(profile => [false, true].map(advanced => ({ profile, advanced }))),
{ advanced: true, homeworlds: true, tech: true },
...(['ixians', 'richese', 'ecaz'] as const).map(profile =>
({ profile, advanced: true, homeworlds: true, tech: true })),
];
for (const scenario of scenarios) for (const use of [false, true]) void test(
`${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.profile ?? 'classic'} original BANK${scenario.homeworlds ? ' Homeworld/Tech' : ''}: ${use ? 'Use retains the positive bid' : 'Pay debits the original bid'}`, () => {
const f = createNexusEmperorBankFixture(scenario), original = structuredClone(f.game);
const player = nexusEmperorBankPlayer(f.game, f.owner), offer = viewGame(f.game, f.owner).nexusEmperorSecretAlly;
assert.ok(offer?.purchase && !offer.purchase.blocked);
assert.ok(f.price > 0 && player.spice >= f.price);
assert.equal(player.ally, null);
assert.ok(!f.game.players.some(p => p.faction === 'emperor'));
assert.deepEqual(viewGame(f.game, 'atreides').auction!.card, f.card);
const expectedBonus = player.faction === 'harkonnen' ? structuredClone(f.game.deck[0]) : null;
const action: Action = use ? { type: 'nexusEmperorPurchase', event: offer.event }
: { type: 'decision', karama: false };
let game = stepNexusEmperorBank(f.game, { actor: f.owner, action });
assert.deepEqual(f.game, original);
assert.deepEqual(nexusEmperorBankPlayer(game, f.owner).hand.find(c => c.id === f.card.id), f.card);
assert.equal(game.nexusCards!.cards!.hands[f.owner], use ? null : 'emperor');
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length, use ? 1 : 0);
game = finishOriginalLot(f, game);
for (const p of game.players)
assert.equal(p.spice, f.wallets[p.id] - (p.id === f.owner && !use ? f.price : 0),
'Only ordinary Pay debits the buyer; no other seat receives bank money');
if (expectedBonus) assert.deepEqual(nexusEmperorBankPlayer(game, f.owner).hand.find(c => c.id === expectedBonus.id), expectedBonus);
assert.deepEqual(game.techTokens, original.techTokens);
assert.deepEqual(game.homeworlds, original.homeworlds);
if (scenario.profile === 'richese') {
assert.ok(f.game.richeseRemoved!.length > 0, 'The original unbid cache lot was resolved before the bank pool');
assert.equal(f.game.richeseBidding!.stage, 'normal');
assert.deepEqual(game.richeseCache, original.richeseCache);
assert.deepEqual(game.richeseRemoved, original.richeseRemoved);
}
nexusEmperorBankInventory(game);
if (use) rejectsUnchanged(game, f.owner, action);
},
);
for (const profile of natives) void test(`${profile}: a real irrelevant closing face still pauses at the same public payment boundary`, () => {
const held = createNexusEmperorBankFixture({ profile });
const irrelevant = createNexusEmperorBankFixture({ profile, nexusCard: 'guild' });
assert.deepEqual(irrelevant.game.decision, held.game.decision);
assert.equal(viewGame(irrelevant.game, irrelevant.owner).nexusEmperorSecretAlly, null);
rejectsUnchanged(irrelevant.game, irrelevant.owner, { type: 'nexusEmperorPurchase', event: irrelevant.event });
const game = finishOriginalLot(irrelevant, stepNexusEmperorBank(irrelevant.game,
{ actor: irrelevant.owner, action: { type: 'decision', karama: false } }));
assert.equal(nexusEmperorBankPlayer(game, irrelevant.owner).spice, irrelevant.wallets[irrelevant.owner] - irrelevant.price);
assert.equal(game.nexusCards!.cards!.hands[irrelevant.owner], 'guild');
nexusEmperorBankInventory(game);
});
void test('native BANK purchase rejects foreign, stale and extra-cost inputs before changing original custody', () => {
const f = createNexusEmperorBankFixture({ profile: 'ixians', advanced: true });
for (const p of f.game.players.filter(p => p.id !== f.owner))
rejectsUnchanged(f.game, p.id, { type: 'nexusEmperorPurchase', event: f.event });
for (const action of [
{ type: 'nexusEmperorPurchase', event: 'stale' },
{ type: 'nexusEmperorPurchase', event: f.event, price: 0 },
{ type: 'nexusEmperorPurchase', event: f.event, allyPayment: 1 },
{ type: 'nexusEmperorPurchase', event: f.event, card: f.card.id },
{ type: 'nexusEmperorPurchase', event: f.event, use: true },
]) rejectsUnchanged(f.game, f.owner, action);
const wallet = nexusEmperorBankPlayer(f.chronology.beforeBid, f.owner).spice;
rejectsUnchanged(f.chronology.beforeBid, f.owner, { type: 'bid', amount: wallet + 1, allyPayment: 0 });
nexusEmperorBankInventory(f.game);
});
void test('a genuinely setup-dealt Truthtrance takes priority over the original native bank purchase', () => {
const f = createNexusEmperorBankFixture({ profile: 'tleilaxu', advanced: true });
const truth = nexusEmperorBankPlayer(f.game, 'atreides').hand.find(c => c.effect === 'truthtrance');
assert.ok(truth, 'Truthtrance must come from the original setup deal');
let game = stepNexusEmperorBank(f.game, { actor: 'atreides', action: { type: 'card', card: truth.id } });
assert.equal(game.truthtrance!.stage, 'priority');
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPurchase', event: f.event });
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
game = stepNexusEmperorBank(game, { actor, action: { type: 'truthPass' } });
}
const name = nexusEmperorBankPlayer(game, f.owner).hand[0].name;
game = stepNexusEmperorBank(game, { actor: 'atreides', action: { type: 'truthAsk',
question: { kind: 'fact', target: f.owner, fact: { kind: 'hand', name } } } });
assert.equal(viewGame(game, f.owner).truthAnswer, 'yes');
game = stepNexusEmperorBank(game, { actor: f.owner, action: { type: 'truthAnswer', answer: 'yes' } });
const offer = viewGame(game, f.owner).nexusEmperorSecretAlly;
assert.ok(offer?.purchase && !offer.purchase.blocked);
game = finishOriginalLot(f, stepNexusEmperorBank(game, { actor: f.owner,
action: { type: 'nexusEmperorPurchase', event: offer.event } }));
assert.equal(nexusEmperorBankPlayer(game, f.owner).spice, f.wallets[f.owner]);
assert.equal(game.discard.filter(c => c.id === truth.id).length, 1);
nexusEmperorBankInventory(game);
});
void test('minimal Easy policy legally completes the actual native bank boundary for both private faces', () => {
for (const nexusCard of ['emperor', 'guild'] as const) {
const f = createNexusEmperorBankFixture({ profile: 'choam', advanced: true, nexusCard });
const view = viewGame(f.game, f.owner);
view.players.find(p => p.id === f.owner)!.bot = 'Easy';
const action = botActions(view)[0]; assert.ok(action);
const game = finishOriginalLot(f, stepNexusEmperorBank(f.game, { actor: f.owner, action }));
assert.equal(nexusEmperorBankPlayer(game, f.owner).spice, f.wallets[f.owner] - (nexusCard === 'emperor' ? 0 : f.price));
assert.equal(game.nexusCards!.cards!.hands[f.owner], nexusCard === 'emperor' ? null : 'guild');
nexusEmperorBankInventory(game);
}
});
void test('native bank evidence stays historical after a real later purchase changes wallet and hand', () => {
const f = createNexusEmperorBankFixture({ profile: 'richese', advanced: true, homeworlds: true, tech: true });
let game = finishOriginalLot(f, stepNexusEmperorBank(f.game,
{ actor: f.owner, action: { type: 'nexusEmperorPurchase', event: f.event } }));
while (game.auction!.active !== f.owner)
game = stepNexusEmperorBank(game, { actor: game.auction!.active, action: { type: 'passBid' } });
const card = structuredClone(game.auction!.cards[game.auction!.index]);
game = stepNexusEmperorBank(game, { actor: f.owner, action: { type: 'bid', amount: 1, allyPayment: 0 } });
game = advanceNexusEmperorBank(game, g => g.decision?.kind === 'auctionPayment');
game = stepNexusEmperorBank(game, { actor: f.owner, action: { type: 'decision', karama: false } });
assert.equal(nexusEmperorBankPlayer(game, f.owner).spice, f.wallets[f.owner] - 1);
assert.deepEqual(nexusEmperorBankPlayer(game, f.owner).hand.find(c => c.id === card.id), card);
nexusEmperorBankInventory(game);
});
-3
View File
@@ -10,7 +10,6 @@ import { createHomeworldCustody } from '../game/homeworld-custody';
import {
createEmperorNexusRevival,
emperorNexusEvent,
emperorNexusModeSupported,
emperorNexusPools,
emperorNexusSignature,
quoteEmperorNexusRevival,
@@ -97,7 +96,6 @@ void test('native typed Tanks determine exact-three choices and Ix Cyborgs are n
for (const advanced of [false, true]) {
const ix = sample('ixians', advanced);
ix.players[0].elites!.revived = 4;
assert.equal(emperorNexusModeSupported(ix), false);
assert.equal(quoteEmperorNexusRevival(ix, 'owner').blocked, null);
assert.deepEqual(quoteEmperorNexusRevival(ix, 'owner').eliteOptions, [0, 1, 2, 3]);
assert.equal(createEmperorNexusRevival(ix, 'owner', 3).elite, 3);
@@ -129,7 +127,6 @@ void test('original native modules allow revival independently of classic purcha
const before = structuredClone(g);
createEmperorNexusRevival(g, 'owner', 0);
assert.deepEqual(g, before);
if (faction !== 'atreides') assert.equal(emperorNexusModeSupported(g), false);
g.homeworlds = { custody: createHomeworldCustody({
advanced: g.advanced,
players: g.players.map(p => ({