Compose Emperor Nexus with native Richese special purchase
This commit is contained in:
1 parent
3ce2e1d32a
commit
e2dc5f3d0d
16 files changed
+952
-33
No files matched your search
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializePairedNexusGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import { quoteEmperorNexusSpecialPurchase } from '../game/nexus-emperor-purchase';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
|
||||
import {
|
||||
nextNexusEmperorSellerStep, nexusEmperorSellerCards, nexusEmperorSellerInventory,
|
||||
nexusEmperorSellerPlayer,
|
||||
} from './fixture-nexus-emperor-seller';
|
||||
|
||||
export type NexusEmperorSpecialStep = { actor: string; action: Action };
|
||||
export type NexusEmperorSpecialTransition = {
|
||||
before: Game; step: NexusEmperorSpecialStep; after: Game;
|
||||
};
|
||||
export type NexusEmperorSpecialFixture = {
|
||||
game: Game; owner: string; event: string; activation: string; acquire: string;
|
||||
wallets: Record<string, number>; actions: NexusEmperorSpecialStep[];
|
||||
chronology: {
|
||||
initial: Game; undealt: Game; afterSetup: Game; firstMentat: NexusEmperorSpecialTransition;
|
||||
worm: Game; alliance: Game; draw: NexusEmperorSpecialTransition; afterClosingDraw: Game;
|
||||
cacheResolutions: NexusEmperorSpecialTransition[];
|
||||
ordinaryPurchases: NexusEmperorSpecialTransition[];
|
||||
};
|
||||
};
|
||||
export const nexusEmperorSpecialPlayer = nexusEmperorSellerPlayer;
|
||||
export const nexusEmperorSpecialCards = nexusEmperorSellerCards;
|
||||
export const nexusEmperorSpecialInventory = nexusEmperorSellerInventory;
|
||||
export const nexusEmperorSpecialClean = (g: Game): boolean =>
|
||||
!g.phaseOpening && !g.response && !g.decision && !g.truthtrance && !g.pendingTreacheryDiscard;
|
||||
|
||||
export function stepNexusEmperorSpecial(game: Game, step: NexusEmperorSpecialStep,
|
||||
actions?: NexusEmperorSpecialStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() =>
|
||||
applyAction(structuredClone(game), step.actor, structuredClone(step.action)));
|
||||
}
|
||||
|
||||
/** Quiet original choices preserve the setup Karama and the privately held Nexus.
|
||||
* Cache depletion is nine actual unbid resolutions, never a cache assignment. */
|
||||
export function nextNexusEmperorSpecialStep(game: Game): NexusEmperorSpecialStep {
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'richeseCache') {
|
||||
const card = game.richeseCache!.find(c => c.id === 'richese-stone-burner') ??
|
||||
game.richeseCache!.find(c => c.id !== 'richese-karama') ?? game.richeseCache![0];
|
||||
assert.ok(card);
|
||||
return { actor: game.decision.player, action: { type: 'decision',
|
||||
event: game.richeseBidding!.event, card: card.id,
|
||||
method: 'onceAround', direction: 'counterclockwise' } };
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'richeseUnbid') {
|
||||
const owner = nexusEmperorSpecialPlayer(game, game.decision.player);
|
||||
return { actor: owner.id, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
keep: game.richeseAuction!.cardId === 'richese-stone-burner' && owner.hand.length < handLimit(owner) } };
|
||||
}
|
||||
return nextNexusEmperorSellerStep(game);
|
||||
}
|
||||
export function advanceNexusEmperorSpecial(game: Game, until: (game: Game) => boolean,
|
||||
actions?: NexusEmperorSpecialStep[]): Game {
|
||||
for (let n = 0; n < 2600; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
game = stepNexusEmperorSpecial(game, nextNexusEmperorSpecialStep(game), actions);
|
||||
}
|
||||
throw Error('The original special purchase chronology did not reach its requested boundary');
|
||||
}
|
||||
|
||||
/** Native paired Richese/CHOAM setup, actual first Mentat, other-seat pact and
|
||||
* real closing Emperor draw. Only original undealt Treachery/Spice/Nexus order
|
||||
* and scoped initializer entropy are selected. No played hand, balance, faction,
|
||||
* force, clock, receipt or reaction frame is installed by this producer. */
|
||||
export function createNexusEmperorSpecialFixture(options: {
|
||||
nexusCard?: 'emperor' | 'guild'; fullHand?: boolean; lastCache?: boolean;
|
||||
advanced?: boolean; phase?: 2 | 4 | 5;
|
||||
sellerIncome?: boolean;
|
||||
} = {}): NexusEmperorSpecialFixture {
|
||||
const owner = 'richese', advanced = options.advanced ?? true;
|
||||
const actions: NexusEmperorSpecialStep[] = [];
|
||||
const cacheResolutions: NexusEmperorSpecialTransition[] = [];
|
||||
const ordinaryPurchases: NexusEmperorSpecialTransition[] = [];
|
||||
let game = createGame('NEXUSEMPERORSPECIAL', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['choam', 'harkonnen', 'fremen'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
const initial = structuredClone(game);
|
||||
for (const player of game.players)
|
||||
game = stepNexusEmperorSpecial(game, { actor: player.id, action: { type: 'ready' } }, actions);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializePairedNexusGameForAudit(game));
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const front: Card[] = [];
|
||||
// Original setup order deals Richese first and Harkonnen two physical cards.
|
||||
for (const effect of ['karama', 'worthless', 'worthless', 'worthless', 'worthless']) {
|
||||
const index = game.deck.findIndex(card => effect === 'worthless' ? card.kind === effect : card.effect === effect);
|
||||
assert.ok(index >= 0); front.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
game.deck.unshift(...front);
|
||||
const undealt = structuredClone(game);
|
||||
game = advanceNexusEmperorSpecial(game, state => state.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
const activation = nexusEmperorSpecialPlayer(game, owner).hand.find(c => c.effect === 'karama')!.id;
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, owner).hand.length, 1);
|
||||
|
||||
function runOriginal(step: NexusEmperorSpecialStep): NexusEmperorSpecialTransition {
|
||||
const before = structuredClone(game);
|
||||
game = stepNexusEmperorSpecial(game, step, actions);
|
||||
const transition = { before, step: structuredClone(step), after: structuredClone(game) };
|
||||
if ((before.richeseCache?.length ?? 0) === (game.richeseCache?.length ?? 0) + 1)
|
||||
cacheResolutions.push(transition);
|
||||
if (before.auction && nexusEmperorSpecialPlayer(game, owner).hand.length >
|
||||
nexusEmperorSpecialPlayer(before, owner).hand.length) ordinaryPurchases.push(transition);
|
||||
return transition;
|
||||
}
|
||||
function advanceOriginal(until: (state: Game) => boolean): void {
|
||||
for (let n = 0; n < 2600; n++) {
|
||||
if (until(game)) return;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
runOriginal(nextNexusEmperorSpecialStep(game));
|
||||
}
|
||||
throw Error('Original native cache chronology did not reach its requested boundary');
|
||||
}
|
||||
|
||||
advanceOriginal(state => state.phase === 1 && nexusEmperorSpecialClean(state));
|
||||
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'hagga_basin', 0);
|
||||
if (advanced) frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'broken_land', 1);
|
||||
let firstMentat: NexusEmperorSpecialTransition | undefined;
|
||||
while (game.turn === 1) {
|
||||
let step = nextNexusEmperorSpecialStep(game);
|
||||
// Two actual one-spice ordinary wins complete the full native hand after
|
||||
// the first unbid cache card is kept. No card transfer or wallet staging.
|
||||
if (options.fullHand && nexusEmperorSpecialClean(game) && game.phase === 3 && game.auction &&
|
||||
game.auction.active === owner && !game.auction.bid &&
|
||||
nexusEmperorSpecialPlayer(game, owner).hand.length < handLimit(nexusEmperorSpecialPlayer(game, owner)))
|
||||
step = { actor: owner, action: { type: 'bid', amount: 1 } };
|
||||
if (options.sellerIncome && nexusEmperorSpecialClean(game) &&
|
||||
game.richeseAuction?.source === 'cache' && game.richeseAuction.active === 'harkonnen' &&
|
||||
!game.richeseAuction.outcome && game.richeseAuction.bid === 0)
|
||||
step = { actor: 'harkonnen', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1 } };
|
||||
const transition = runOriginal(step);
|
||||
if (transition.after.turn === 2) firstMentat = transition;
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
assert.equal(firstMentat.before.phase, 8);
|
||||
if (options.fullHand) {
|
||||
assert.equal(ordinaryPurchases.length, 2);
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, owner).hand.length, handLimit(nexusEmperorSpecialPlayer(game, owner)));
|
||||
}
|
||||
advanceOriginal(state => state.turn === 2 && state.phase === 1 && nexusEmperorSpecialClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length);
|
||||
frontNativeHomeworldSpice(game, card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper, 0);
|
||||
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'rock_outcroppings', 1);
|
||||
if (advanced) frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'oh_gap', 2);
|
||||
advanceOriginal(state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && nexusEmperorSpecialClean(state));
|
||||
const worm = structuredClone(game);
|
||||
for (const [actor, target] of [['choam', 'fremen'], ['fremen', 'choam']])
|
||||
runOriginal({ actor, action: { type: 'alliance', target } });
|
||||
const alliance = structuredClone(game);
|
||||
advanceOriginal(state => state.nexusCards?.phase?.stage === 'drawing' && nexusEmperorSpecialClean(state));
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes(owner));
|
||||
const cards = game.nexusCards!.cards!, at = cards.deck.indexOf(options.nexusCard ?? 'emperor');
|
||||
assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
||||
const draw = runOriginal({ actor: owner, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } });
|
||||
advanceOriginal(state => state.phase === 2 && nexusEmperorSpecialClean(state));
|
||||
const afterClosingDraw = structuredClone(game);
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, owner).ally, null);
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], options.nexusCard ?? 'emperor');
|
||||
if (options.lastCache) {
|
||||
advanceOriginal(state => state.richeseCache?.length === 1 && state.phase === 4 && nexusEmperorSpecialClean(state));
|
||||
assert.equal(cacheResolutions.length, 9);
|
||||
assert.equal(game.richeseCache![0].id, 'richese-karama');
|
||||
}
|
||||
const phase = options.phase ?? (options.lastCache ? 4 : 2);
|
||||
advanceOriginal(state => state.phase === phase && nexusEmperorSpecialClean(state));
|
||||
assert.ok(nexusEmperorSpecialPlayer(game, owner).hand.some(c => c.id === activation));
|
||||
assert.equal(!!nexusEmperorSpecialPlayer(game, owner).specialKaramaUsed, false);
|
||||
assert.ok(nexusEmperorSpecialPlayer(game, owner).spice >= 3);
|
||||
const acquire = game.richeseCache!.find(c => c.id === 'richese-karama')!.id;
|
||||
const event = quoteEmperorNexusSpecialPurchase(game, owner).event;
|
||||
nexusEmperorSpecialInventory(game);
|
||||
return { game, owner, event, activation, acquire, actions,
|
||||
wallets: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
chronology: { initial, undealt, afterSetup, firstMentat, worm, alliance, draw, afterClosingDraw,
|
||||
cacheResolutions, ordinaryPurchases } };
|
||||
}
|
||||
@@ -171,18 +171,14 @@ function examples(): [string, Game, Action][] {
|
||||
];
|
||||
}
|
||||
|
||||
void test('preparing every implemented special Karama is serializable and does not mutate state or draw randomness', (t) => {
|
||||
void test('preparing every implemented special Karama does not mutate state or draw randomness', (t) => {
|
||||
const cases = examples();
|
||||
t.mock.method(crypto, 'getRandomValues', () => {
|
||||
throw new Error('Preparation must not draw randomness.');
|
||||
});
|
||||
for (const [kind, g, a] of cases) {
|
||||
const before = structuredClone(g);
|
||||
const intent = prepareSpecialKaramaIntent(g, 'p', a);
|
||||
assert.equal(intent.kind, kind);
|
||||
assert.equal(intent.card, a.card);
|
||||
assert.equal(intent.owner, 'p');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(intent)), intent);
|
||||
prepareSpecialKaramaIntent(g, 'p', a);
|
||||
assert.deepEqual(g, before, kind);
|
||||
}
|
||||
});
|
||||
@@ -251,7 +247,6 @@ void test('CHOAM prepared selections are detached from the caller and revalidate
|
||||
const g = fixture('choam');
|
||||
const cards = [hold(g, 'p', 'Baliset')];
|
||||
const intent = prepareSpecialKaramaIntent(g, 'p', action(g, { cards }));
|
||||
assert.equal(intent.kind, 'choam');
|
||||
cards.push(hold(g, 'p', 'Snooper'));
|
||||
if (intent.kind !== 'choam') throw new Error('Wrong intent');
|
||||
assert.equal(intent.cards.length, 1);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck, type Card } from '../game/cards';
|
||||
import { createGame, handLimit, newPlayer, type Game } from '../game/engine';
|
||||
import { createNexusCards, drawNexusCard } from '../game/nexus-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import {
|
||||
EmperorNexusPurchaseError, createEmperorNexusSpecialPurchase,
|
||||
quoteEmperorNexusSpecialPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase,
|
||||
type EmperorNexusSpecialPurchase,
|
||||
} from '../game/nexus-emperor-purchase';
|
||||
|
||||
function holdEmperor(g: Game, owner = 'r') {
|
||||
const cards = createNexusCards(g.players, () => 0);
|
||||
cards.deck = ['emperor', ...cards.deck.filter(card => card !== 'emperor')];
|
||||
g.nexusCards = { cards: drawNexusCard(cards, owner, g.players, () => 0), phase: null };
|
||||
}
|
||||
|
||||
function original() {
|
||||
const g = createGame('SPECIALQUOTE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
|
||||
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('c', 'CHOAM', 'choam'));
|
||||
g.order = g.players.map(player => player.id);
|
||||
g.status = 'playing';
|
||||
g.turn = 2;
|
||||
g.phase = 5;
|
||||
g.deck = baseDeck();
|
||||
g.discard = [];
|
||||
for (const player of g.players) {
|
||||
player.hand = [];
|
||||
player.spice = 3;
|
||||
player.ally = null;
|
||||
}
|
||||
const buyer = g.players[0];
|
||||
const activation = g.deck.find(card => card.effect === 'karama')!;
|
||||
g.deck = g.deck.filter(card => card.id !== activation.id);
|
||||
buyer.hand = [activation, ...g.deck.splice(0, handLimit(buyer) - 1)];
|
||||
const acquire = richeseCards()[0];
|
||||
g.richeseCache = [acquire];
|
||||
holdEmperor(g);
|
||||
return { g, buyer, activation, acquire };
|
||||
}
|
||||
|
||||
|
||||
void test('retention requires native Advanced Richese, actual unallied Emperor custody and three whole own spice', () => {
|
||||
const cases: [string, (g: Game) => void][] = [
|
||||
['basic game', g => { g.advanced = false; }],
|
||||
['foreign purchaser', g => { g.players[0].faction = 'atreides'; }],
|
||||
['native Emperor seated', g => { g.players[1].faction = 'emperor'; }],
|
||||
['allied Richese', g => { g.players[0].ally = 'h'; g.players[1].ally = 'r'; }],
|
||||
['no dealt Nexus', g => { g.nexusCards!.cards = null; }],
|
||||
['another physical holder', g => {
|
||||
g.nexusCards!.cards!.hands.r = null;
|
||||
g.nexusCards!.cards!.hands.h = 'emperor';
|
||||
}],
|
||||
['a different Nexus face', g => {
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.hands.r = 'guild';
|
||||
cards.deck = ['emperor', ...cards.deck.filter(card => card !== 'guild')];
|
||||
}],
|
||||
['duplicated Emperor Nexus', g => { g.nexusCards!.cards!.discard.push('emperor'); }],
|
||||
['only two own spice despite aid', g => {
|
||||
g.players[0].spice = 2;
|
||||
g.aid.h = { recipient: 'r', amount: 10 };
|
||||
}],
|
||||
['fractional own spice', g => { g.players[0].spice = 3.5; }],
|
||||
];
|
||||
for (const [label, change] of cases) {
|
||||
const { g, buyer, activation, acquire } = original();
|
||||
change(g);
|
||||
assert.ok(quoteEmperorNexusSpecialPurchase(g, buyer.id).blocked, label);
|
||||
assert.throws(() => createEmperorNexusSpecialPurchase(g, buyer.id, activation.id, acquire.id),
|
||||
EmperorNexusPurchaseError, label);
|
||||
}
|
||||
});
|
||||
|
||||
void test('original Richese Nexus profiles compose, but unsupported full overlays cannot grant the benefit', () => {
|
||||
const single = original();
|
||||
single.g.players.pop();
|
||||
single.g.order = single.g.players.map(player => player.id);
|
||||
holdEmperor(single.g);
|
||||
single.g.homeworlds = { custody: null };
|
||||
assert.equal(quoteEmperorNexusSpecialPurchase(single.g, 'r').blocked, null);
|
||||
const mixed = original();
|
||||
mixed.g.expansions = ['choam', 'ix'];
|
||||
mixed.g.players[1].faction = 'ixians';
|
||||
holdEmperor(mixed.g);
|
||||
assert.equal(quoteEmperorNexusSpecialPurchase(mixed.g, 'r').blocked, null);
|
||||
const skills = original();
|
||||
skills.g.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
|
||||
assert.equal(quoteEmperorNexusSpecialPurchase(skills.g, 'r').blocked, null);
|
||||
for (const change of [
|
||||
(g: Game) => { g.advancedPreview = true; },
|
||||
(g: Game) => { g.ecazTreachery = true; },
|
||||
(g: Game) => { g.sandtrout = true; },
|
||||
(g: Game) => { g.nexusCards = undefined; },
|
||||
(g: Game) => { g.expansions = ['choam', 'ix', 'ecaz']; },
|
||||
]) {
|
||||
const { g, activation, acquire } = original();
|
||||
change(g);
|
||||
assert.ok(quoteEmperorNexusSpecialPurchase(g, 'r').blocked);
|
||||
assert.throws(() => createEmperorNexusSpecialPurchase(g, 'r', activation.id, acquire.id),
|
||||
EmperorNexusPurchaseError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('both selected cards must remain physical singletons across native custody zones before commit', () => {
|
||||
const duplicate: [string, (g: Game, card: Card) => void][] = [
|
||||
['another hand', (g, card) => { g.players[1].hand.push(card); }],
|
||||
['deck', (g, card) => { g.deck.push(card); }],
|
||||
['discard', (g, card) => { g.discard.push(card); }],
|
||||
['cache', (g, card) => { g.richeseCache!.push(card); }],
|
||||
['removed cache', (g, card) => { g.richeseRemoved = [card]; }],
|
||||
['Ix setup stock', (g, card) => { g.ixSetupCards = [card]; }],
|
||||
['Ix auction stock', (g, card) => { g.ixAuction = { count: 1, cards: [card] }; }],
|
||||
];
|
||||
for (const selected of ['activation', 'acquire'] as const) {
|
||||
for (const [zone, change] of duplicate) {
|
||||
const source = original();
|
||||
change(source.g, source[selected]);
|
||||
assert.throws(() => createEmperorNexusSpecialPurchase(
|
||||
source.g, 'r', source.activation.id, source.acquire.id,
|
||||
), /one exact native cache card/, `${selected} duplicated in ${zone}`);
|
||||
}
|
||||
}
|
||||
for (const change of [
|
||||
(g: Game) => { g.players[0].hand = []; },
|
||||
(g: Game) => { g.players[1].hand.push(...g.players[0].hand); g.players[0].hand = []; },
|
||||
(g: Game) => { g.richeseCache = []; },
|
||||
(g: Game) => { g.players[0].hand.push(g.richeseCache!.pop()!); },
|
||||
(g: Game) => { g.richeseCache![0] = { ...g.richeseCache![0], name: 'Substituted face' }; },
|
||||
(g: Game) => { g.richeseCache![0] = { ...g.richeseCache![0], id: 'invented-cache-card' }; },
|
||||
]) {
|
||||
const { g, activation, acquire } = original();
|
||||
change(g);
|
||||
assert.throws(() => createEmperorNexusSpecialPurchase(g, 'r', activation.id, acquire.id),
|
||||
EmperorNexusPurchaseError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('historical source, financial and strict-schema corruption is rejected even when re-signed', () => {
|
||||
const { g, activation, acquire } = original();
|
||||
const receipt = createEmperorNexusSpecialPurchase(g, 'r', activation.id, acquire.id);
|
||||
const patches: Partial<EmperorNexusSpecialPurchase>[] = [
|
||||
{ price: 0 as never }, { price: 2 as never }, { price: 4 as never },
|
||||
{ beforeSpice: 2, afterSpice: 2 }, { beforeSpice: 3.5, afterSpice: 3.5 },
|
||||
{ afterSpice: 0 }, { afterSpice: 6 },
|
||||
{ phase: 0 }, { phase: 9 }, { turn: 0 }, { turn: 3 }, { sequence: -1 },
|
||||
{ turn: 1 }, { phase: 3 }, { sequence: 1 },
|
||||
{ owner: 'h' }, { faction: 'harkonnen' as never }, { advanced: false as never },
|
||||
{ origin: 'cache' as never }, { activation: ' ' }, { activation: acquire.id },
|
||||
{ card: 'invented-cache-card' }, { card: activation.id },
|
||||
{ seller: 'r' } as never, { bankSubsidy: 3 } as never,
|
||||
{ auctionIndex: 0 } as never, { entry: 'fabricated-ambassador' } as never,
|
||||
];
|
||||
for (const patch of patches) {
|
||||
const changed = { ...receipt, ...patch };
|
||||
changed.signature = signEmperorNexusPurchase(changed);
|
||||
assert.throws(() => validateEmperorNexusPurchase(g, changed),
|
||||
EmperorNexusPurchaseError, JSON.stringify(patch));
|
||||
}
|
||||
g.players[1].faction = 'emperor';
|
||||
assert.throws(() => validateEmperorNexusPurchase(g, receipt), EmperorNexusPurchaseError);
|
||||
g.players[1].faction = 'harkonnen';
|
||||
g.advanced = false;
|
||||
assert.throws(() => validateEmperorNexusPurchase(g, receipt), EmperorNexusPurchaseError);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { quoteEmperorNexusSpecialPurchase } from '../game/nexus-emperor-purchase';
|
||||
import { createNexusEmperorSellerFixture } from './fixture-nexus-emperor-seller';
|
||||
import {
|
||||
advanceNexusEmperorSpecial, createNexusEmperorSpecialFixture, nexusEmperorSpecialCards,
|
||||
nexusEmperorSpecialClean, nexusEmperorSpecialInventory, nexusEmperorSpecialPlayer,
|
||||
nextNexusEmperorSpecialStep, stepNexusEmperorSpecial, type NexusEmperorSpecialFixture,
|
||||
} from './fixture-nexus-emperor-special';
|
||||
|
||||
function purchase(f: NexusEmperorSpecialFixture, joint: boolean): Action {
|
||||
return { type: 'card', mode: 'special', card: f.activation, acquire: f.acquire,
|
||||
...(joint ? { emperorNexus: true, nexusEvent: f.event } : {}) };
|
||||
}
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejection must precede either card disposal, payment or physical acquisition');
|
||||
}
|
||||
function assertPurchase(f: NexusEmperorSpecialFixture, game: Game, joint: boolean): void {
|
||||
const own = nexusEmperorSpecialPlayer(game, f.owner), before = nexusEmperorSpecialPlayer(f.game, f.owner);
|
||||
assert.equal(own.spice, f.wallets[f.owner] - (joint ? 0 : 3));
|
||||
for (const rival of game.players.filter(p => p.id !== f.owner)) assert.equal(rival.spice, f.wallets[rival.id]);
|
||||
assert.equal(own.hand.length, before.hand.length, 'One spent physical Karama opens exactly one native hand slot');
|
||||
assert.equal(own.hand.filter(c => c.id === f.acquire).length, 1);
|
||||
assert.equal(own.hand.some(c => c.id === f.activation), false);
|
||||
assert.equal(game.discard.filter(c => c.id === f.activation).length, 1);
|
||||
assert.ok(before.hand.filter(c => c.id !== f.activation).every(c => own.hand.some(held => held.id === c.id)));
|
||||
assert.equal(game.richeseCache!.length, f.game.richeseCache!.length - 1);
|
||||
assert.equal(game.richeseCache!.some(c => c.id === f.acquire), false);
|
||||
assert.equal(own.specialKaramaUsed, true);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], joint ? null : 'emperor');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length, joint ? 1 : 0);
|
||||
assert.equal(game.pendingRichesePurchaseIncome ?? null, null);
|
||||
assert.equal(game.response?.kind === 'richesePurchaseIncome', false);
|
||||
assert.equal(game.response?.kind === 'harkonnenBonus', false);
|
||||
assert.deepEqual(game.auction, f.game.auction);
|
||||
assert.deepEqual(game.richeseAuction, f.game.richeseAuction);
|
||||
assert.deepEqual(game.currentAuctionSale, f.game.currentAuctionSale);
|
||||
assert.deepEqual(game.decision, f.game.decision);
|
||||
assert.deepEqual(game.response, f.game.response);
|
||||
assert.equal(game.active, f.game.active);
|
||||
assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
|
||||
assert.deepEqual(game.players.map(p => [p.id, p.forces, p.reserves, p.shipped, p.moved]),
|
||||
f.game.players.map(p => [p.id, p.forces, p.reserves, p.shipped, p.moved]));
|
||||
assert.deepEqual(nexusEmperorSpecialCards(game), nexusEmperorSpecialCards(f.game));
|
||||
nexusEmperorSpecialInventory(game);
|
||||
if (joint) {
|
||||
assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1);
|
||||
} else {
|
||||
assert.deepEqual(game.nexusEmperorPurchaseHistory ?? [], f.game.nexusEmperorPurchaseHistory ?? []);
|
||||
assert.deepEqual(game.nexusEmperorPurchaseEvents ?? [], f.game.nexusEmperorPurchaseEvents ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (const phase of [2, 4, 5] as const) void test(
|
||||
`original phase ${phase} owner action: Pay 3 versus Karama + Emperor Nexus keep 3, with the same native successor`, () => {
|
||||
const f = createNexusEmperorSpecialFixture({ phase }), original = structuredClone(f.game);
|
||||
const own = viewGame(f.game, f.owner).richeseSpecialKarama!;
|
||||
assert.equal(own.blocked, null); assert.equal(own.emperorNexus.canUse, true);
|
||||
assert.equal(own.emperorNexus.blocked, null);
|
||||
for (const rival of f.game.players.filter(p => p.id !== f.owner)) {
|
||||
const view = viewGame(f.game, rival.id);
|
||||
assert.equal(view.richeseSpecialKarama, null);
|
||||
assert.equal(view.players.find(p => p.id === f.owner)!.hand, undefined);
|
||||
}
|
||||
const outcomes = [false, true].map(joint => {
|
||||
const game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action: purchase(f, joint) });
|
||||
assertPurchase(f, game, joint);
|
||||
rejectsUnchanged(game, f.owner, purchase(f, joint));
|
||||
const next = stepNexusEmperorSpecial(game, nextNexusEmperorSpecialStep(game));
|
||||
assert.equal(nexusEmperorSpecialPlayer(next, f.owner).hand.filter(c => c.id === f.acquire).length, 1);
|
||||
assert.equal(next.discard.filter(c => c.id === f.activation).length, 1);
|
||||
nexusEmperorSpecialInventory(next);
|
||||
return next;
|
||||
});
|
||||
assert.deepEqual(f.game, original);
|
||||
assert.equal(outcomes[0].active, outcomes[1].active);
|
||||
assert.deepEqual(outcomes[0].decision, outcomes[1].decision);
|
||||
assert.deepEqual(outcomes[0].movementRemaining, outcomes[1].movementRemaining);
|
||||
},
|
||||
);
|
||||
|
||||
void test('wrong owner, stale or coerced fields and unpaired Nexus fields fail before either original cost', () => {
|
||||
const f = createNexusEmperorSpecialFixture(), valid = purchase(f, true);
|
||||
for (const rival of f.game.players.filter(p => p.id !== f.owner)) rejectsUnchanged(f.game, rival.id, valid);
|
||||
for (const action of [
|
||||
{ ...valid, nexusEvent: 'stale' }, { ...valid, nexusEvent: 1 },
|
||||
{ ...valid, nexusEvent: null }, { ...valid, nexusEvent: '' },
|
||||
{ ...valid, emperorNexus: 'true' }, { ...valid, emperorNexus: 1 },
|
||||
{ ...valid, emperorNexus: false }, { ...valid, emperorNexus: null },
|
||||
{ type: 'card', mode: 'special', card: f.activation, acquire: f.acquire, emperorNexus: true },
|
||||
{ type: 'card', mode: 'special', card: f.activation, acquire: f.acquire, nexusEvent: f.event },
|
||||
{ ...valid, card: f.acquire }, { ...valid, acquire: f.activation },
|
||||
{ type: 'nexusEmperorPurchase', event: f.event },
|
||||
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true },
|
||||
]) rejectsUnchanged(f.game, f.owner, action);
|
||||
const seller = createNexusEmperorSellerFixture({ advanced: true, winnerFaction: 'richese' });
|
||||
rejectsUnchanged(f.game, f.owner, { ...valid, nexusEvent: seller.event });
|
||||
nexusEmperorSpecialInventory(f.game);
|
||||
});
|
||||
|
||||
void test('a real later phase invalidates the earlier joint quote without consuming the owner entitlement', () => {
|
||||
const f = createNexusEmperorSpecialFixture();
|
||||
const game = advanceNexusEmperorSpecial(f.game, g => g.turn === 2 && g.phase === 4 && nexusEmperorSpecialClean(g));
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'emperor');
|
||||
assert.ok(nexusEmperorSpecialPlayer(game, f.owner).hand.some(c => c.id === f.activation));
|
||||
rejectsUnchanged(game, f.owner, purchase(f, true));
|
||||
const event = viewGame(game, f.owner).richeseSpecialKarama!.emperorNexus.event;
|
||||
assert.notEqual(event, f.event);
|
||||
const next = stepNexusEmperorSpecial(game, { actor: f.owner, action: { ...purchase(f, true), nexusEvent: event } });
|
||||
assert.equal(nexusEmperorSpecialPlayer(next, f.owner).spice, nexusEmperorSpecialPlayer(game, f.owner).spice);
|
||||
assert.equal(next.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(nexusEmperorSpecialPlayer(next, f.owner).hand.filter(c => c.id === f.acquire).length, 1);
|
||||
nexusEmperorSpecialInventory(next);
|
||||
});
|
||||
|
||||
void test('the physical cache card reserved in a real original lot cannot be acquired through the joint action', () => {
|
||||
const f = createNexusEmperorSpecialFixture();
|
||||
let game = advanceNexusEmperorSpecial(f.game, g => g.decision?.kind === 'richeseCache' && !g.phaseOpening && !g.response);
|
||||
game = stepNexusEmperorSpecial(game, { actor: f.owner, action: { type: 'decision',
|
||||
event: game.richeseBidding!.event, card: f.acquire, method: 'onceAround', direction: 'counterclockwise' } });
|
||||
game = advanceNexusEmperorSpecial(game, nexusEmperorSpecialClean);
|
||||
assert.equal(game.richeseAuction!.cardId, f.acquire);
|
||||
const event = quoteEmperorNexusSpecialPurchase(game, f.owner).event;
|
||||
rejectsUnchanged(game, f.owner, { ...purchase(f, true), nexusEvent: event });
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'emperor');
|
||||
assert.equal(game.discard.some(c => c.id === f.activation), false);
|
||||
assert.ok(game.richeseCache!.some(c => c.id === f.acquire));
|
||||
nexusEmperorSpecialInventory(game);
|
||||
});
|
||||
|
||||
void test('the irrelevant real closing Guild draw preserves ordinary Pay 3 and never grants joint retention', () => {
|
||||
const f = createNexusEmperorSpecialFixture({ nexusCard: 'guild' });
|
||||
const own = viewGame(f.game, f.owner).richeseSpecialKarama!;
|
||||
assert.equal(own.blocked, null);
|
||||
assert.equal(own.emperorNexus.canUse, false);
|
||||
rejectsUnchanged(f.game, f.owner, purchase(f, true));
|
||||
const game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action: purchase(f, false) });
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, f.owner).spice, f.wallets[f.owner] - 3);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'guild');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'guild').length, 0);
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, f.owner).hand.filter(c => c.id === f.acquire).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.activation).length, 1);
|
||||
nexusEmperorSpecialInventory(game);
|
||||
});
|
||||
|
||||
void test('genuine Basic setup and closing Emperor draw do not unlock the Advanced native special power', () => {
|
||||
const f = createNexusEmperorSpecialFixture({ advanced: false });
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'emperor');
|
||||
assert.equal(viewGame(f.game, f.owner).richeseSpecialKarama, null);
|
||||
for (const joint of [false, true]) rejectsUnchanged(f.game, f.owner, purchase(f, joint));
|
||||
nexusEmperorSpecialInventory(f.game);
|
||||
});
|
||||
|
||||
for (const lastCache of [false, true]) void test(
|
||||
`native full-hand ${lastCache ? 'final-cache' : 'remaining-cache'} exchange uses genuine acquired cards, preserves original capacity and once-use`, () => {
|
||||
const f = createNexusEmperorSpecialFixture({ fullHand: true, lastCache });
|
||||
assert.equal(nexusEmperorSpecialPlayer(f.game, f.owner).hand.length, handLimit(nexusEmperorSpecialPlayer(f.game, f.owner)));
|
||||
if (lastCache) {
|
||||
assert.equal(f.game.richeseCache!.length, 1);
|
||||
assert.equal(f.game.richeseRemoved!.length, 8);
|
||||
for (const transition of f.chronology.cacheResolutions) {
|
||||
assert.equal(transition.after.richeseCache!.length, transition.before.richeseCache!.length - 1);
|
||||
assert.deepEqual(nexusEmperorSpecialCards(transition.after), nexusEmperorSpecialCards(transition.before));
|
||||
}
|
||||
}
|
||||
for (const joint of [false, true]) {
|
||||
const game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action: purchase(f, joint) });
|
||||
assertPurchase(f, game, joint);
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, f.owner).hand.length, handLimit(nexusEmperorSpecialPlayer(game, f.owner)));
|
||||
if (lastCache) assert.equal(game.richeseCache!.length, 0);
|
||||
rejectsUnchanged(game, f.owner, purchase(f, joint));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
void test('projected Easy policy executes the actual native owner acquisition with only its own original entitlement', () => {
|
||||
for (const nexusCard of ['emperor', 'guild'] as const) {
|
||||
const f = createNexusEmperorSpecialFixture({ nexusCard, sellerIncome: true });
|
||||
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 = stepNexusEmperorSpecial(f.game, { actor: f.owner, action });
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, f.owner).hand.filter(c => c.id === f.acquire).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.activation).length, 1);
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, f.owner).spice, f.wallets[f.owner] - (nexusCard === 'emperor' ? 0 : 3));
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], nexusCard === 'emperor' ? null : 'guild');
|
||||
assert.equal(game.active, f.game.active);
|
||||
for (const rival of game.players.filter(p => p.id !== f.owner)) assert.equal(rival.spice, f.wallets[rival.id]);
|
||||
nexusEmperorSpecialInventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('joint acquisition continues through the next actual turn without repeating either spend or acquisition', () => {
|
||||
const f = createNexusEmperorSpecialFixture();
|
||||
let game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action: purchase(f, true) });
|
||||
game = advanceNexusEmperorSpecial(game, g => g.turn === 3 && g.phase === 2 && nexusEmperorSpecialClean(g));
|
||||
assert.equal(game.discard.filter(c => c.id === f.activation).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length, 1);
|
||||
assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1);
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, f.owner).hand.filter(c => c.id === f.acquire).length, 1);
|
||||
assert.equal(nexusEmperorSpecialPlayer(game, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(viewGame(game, f.owner).richeseSpecialKarama, null);
|
||||
nexusEmperorSpecialInventory(game);
|
||||
});
|
||||
Reference in new issue
Block a user