Admit original native Nexus bank purchases without widening legacy revivals
This commit is contained in:
1 parent
e2dc5f3d0d
commit
172363d9d3
14 files changed
+681
-39
No files matched your search
@@ -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 } };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 => ({
|
||||
|
||||
Reference in new issue
Block a user