Combine High Richese reserves with Nexus Cunning

This commit is contained in:
Quintine committed 2026-10-25 20:35:34 +11:00
1 parent ce676f0e54
commit 61c269123c
15 files changed
+643 -60

No files matched your search

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { viewGame, type Action } from '../game/engine';
import type { NoFieldValue } from '../game/richese-no-field';
import { quoteRicheseHomeworldPair } from '../game/richese-homeworld-shipment';
import {
createRicheseHomeworldShipmentFixture,
actRicheseHomeworldShipment, nextRicheseHomeworldShipment, advanceRicheseHomeworldShipment,
passRicheseHomeworldShipment, finishRicheseHomeworldShipment,
commitRicheseHomeworldShipment, revealRicheseHomeworldShipment,
richeseHomeworldShipmentPlayer, richeseHomeworldShipmentClean,
richeseHomeworldShipmentInventory, richeseHomeworldShipmentNexusInventory,
richeseHomeworldShipmentPopulation,
} from './fixture-richese-homeworld-shipment';
export const actRicheseHomeworldCunning = actRicheseHomeworldShipment;
export const nextRicheseHomeworldCunning = nextRicheseHomeworldShipment;
export const advanceRicheseHomeworldCunning = advanceRicheseHomeworldShipment;
export const passRicheseHomeworldCunning = passRicheseHomeworldShipment;
export const finishRicheseHomeworldCunning = finishRicheseHomeworldShipment;
export const commitRicheseHomeworldCunning = commitRicheseHomeworldShipment;
export const revealRicheseHomeworldCunning = revealRicheseHomeworldShipment;
export const richeseHomeworldCunningPlayer = richeseHomeworldShipmentPlayer;
export const richeseHomeworldCunningClean = richeseHomeworldShipmentClean;
export const richeseHomeworldCunningInventory = richeseHomeworldShipmentInventory;
export const richeseHomeworldCunningNexusInventory = richeseHomeworldShipmentNexusInventory;
export const richeseHomeworldCunningPopulation = richeseHomeworldShipmentPopulation;
/** Original Homeworld+Nexus setup and paid native funding, before any pair
* declaration. Emperor and Atreides actually ally at the turn-two worm; the
* unallied Richese really draws its original Cunning singleton afterward. */
export function createRicheseHomeworldCunningFixture(options: {
advanced?: boolean; companions?: number; revealedValue?: NoFieldValue; concealedValue?: NoFieldValue;
} = {}) {
const revealedValue = options.revealedValue ?? 5, concealedValue = options.concealedValue ?? 3;
assert.notEqual(revealedValue, concealedValue, 'The two original physical tokens must be distinct');
const f = createRicheseHomeworldShipmentFixture({ advanced: options.advanced,
companions: options.companions, value: concealedValue, nexus: true });
assert.ok(f.nexusDraw && f.nexusAlliance && f.observer);
const p = richeseHomeworldCunningPlayer(f.game, f.owner), offer = viewGame(f.game, f.owner).nexusRicheseCunning;
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The actually drawn native Richese Cunning is live');
assert.equal(p.ally, null); assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'richese');
const revealed = p.noField!.tokens.find(token => token.value === revealedValue); assert.ok(revealed);
const concealed = p.noField!.tokens.find(token => token.value === concealedValue); assert.ok(concealed);
const action: Action = { type: 'ship', noField: concealed.id, event: p.noFieldEvent,
revealedToken: revealed.id, nexus: offer.event, noFieldReserves: f.companions,
territory: f.territory, sector: f.sector, allyPayment: 0 };
const quote = quoteRicheseHomeworldPair({ enabled: true, reserves: p.reserves,
amount: f.companions, revealedValue });
return { ...f, source: structuredClone(f.game), beforeShipment: structuredClone(f.game),
singleAction: f.action, action, shipmentAction: action, shipmentStep: { actor: f.owner, action },
nexusEvent: offer.event, offer: structuredClone(offer),
revealedValue, revealedToken: revealed.id, concealedValue, concealedToken: concealed.id,
value: concealedValue, markerValue: concealedValue, token: concealed.id, tokenId: concealed.id,
paid: quote.paid, physical: quote.physical, priced: quote.priced, revealedForces: quote.revealed, pairQuote: quote,
staging: [...f.staging,
'This native three-seat branch stops before the original two-token Cunning declaration. Its Emperor/Atreides alliance is earned, Richese is unallied, the original Richese Nexus is actually held and empty Carthag11 is the funded destination. No two-seat all-unallied draw is manufactured.',
'The proposed pair uses one concealed three token and one immediate faceup five token by default, plus eleven paid original reserves. Root’s explicitly provisional composition reserves the paid group first, materializes only the remaining stock for the immediate token and leaves the other token concealed. The original Nexus spend, No-Field Karama counter, one combined invoice and later reserve-capped reveal remain real caller actions.',
],
};
}
export type RicheseHomeworldCunningFixture = ReturnType<typeof createRicheseHomeworldCunningFixture>;
+89 -9
View File
@@ -3,6 +3,7 @@ import { createGame, joinGame, newPlayer, type Action, type Game } from '../game
import type { NoFieldValue } from '../game/richese-no-field';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
@@ -36,12 +37,24 @@ export const richeseHomeworldShipmentPopulation = (game: Game, owner = 'richese'
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
.find(home => home.native === owner && home.card === 'richese'); assert.ok(home); return home;
};
export const richeseHomeworldShipmentNexusInventory = (game: Game) => {
const cards = game.nexusCards?.cards;
if (!cards) return [];
const ids = [...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort();
assert.equal(new Set(ids).size, ids.length, 'Every original Nexus singleton retains one physical slot');
return ids;
};
/** Native pass/unbid controls only. Cache selection always uses an actual
* remaining card, so a prior retired lot is never requested again. Emperor
* really takes ordinary Charity when eligible; no balance is installed. */
export function nextRicheseHomeworldShipment(game: Game): RicheseHomeworldShipmentStep | null {
if (game.truthtrance) return null;
if (game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: phase.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
if (richeseHomeworldShipmentClean(game)) {
if (game.status === 'playing' && game.phase === 2) {
const emperor = richeseHomeworldShipmentPlayer(game, 'emperor');
@@ -83,15 +96,18 @@ export function finishRicheseHomeworldShipment(game: Game, actions: RicheseHomew
* cards and undeployed original No-Fields. Native deferred bribes fund one
* combined invoice without changing either physical army. Stop BEFORE ship. */
export function createRicheseHomeworldShipmentFixture(options: {
advanced?: boolean; companions?: number; value?: NoFieldValue;
advanced?: boolean; companions?: number; value?: NoFieldValue; nexus?: boolean;
} = {}) {
const advanced = options.advanced ?? false, companions = options.companions ?? 11, value = options.value ?? 5;
const nexus = options.nexus ?? false;
assert.ok(Number.isSafeInteger(companions) && companions >= 1 && companions <= 16,
'The bounded native funding programme supports one through sixteen companions');
const owner = 'richese', holder = 'emperor', territory = 'arrakeen', sector = 10, location = 'arrakeen:10';
const owner = 'richese', holder = 'emperor', observer = nexus ? 'atreides' : null;
const territory = nexus ? 'carthag' : 'arrakeen', sector = nexus ? 11 : 10, location = `${territory}:${sector}`;
const cost = 1 + companions;
let game = createGame('RICHESEHOMEWORLDSHIPMENT', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(holder, 'Emperor', 'emperor'));
if (observer) joinGame(game, newPlayer(observer, 'Atreides', 'atreides'));
const initial = structuredClone(game), actions: RicheseHomeworldShipmentStep[] = [];
const act = (actor: string, action: Action) => { game = actRicheseHomeworldShipment(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceRicheseHomeworldShipment(game, until, actions); };
@@ -100,18 +116,24 @@ export function createRicheseHomeworldShipmentFixture(options: {
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
const enableNexus = nexus ? transition(() => {
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
}) : null;
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
assert.ok(game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
assert.equal(!!game.nexusCards, nexus);
assert.ok(game.homeworlds && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalMainDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id);
assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
const ordinary = game.deck.find(card => card.kind === 'projectile');
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(ordinary && karama);
const deal = [ordinary.id, karama.id];
const observerCard = observer ? game.deck.find(card => card.kind === 'shield') : null;
assert.ok(!observer || observerCard);
const deal = [ordinary.id, karama.id, ...(observerCard ? [observerCard.id] : [])];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
@@ -125,9 +147,13 @@ export function createRicheseHomeworldShipmentFixture(options: {
assert.equal(originalNoField.deployed, null); assert.equal(originalNoField.lastShipped, null);
assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).hand.map(card => card.id), [ordinary.id]);
assert.deepEqual(richeseHomeworldShipmentPlayer(game, holder).hand.map(card => card.id), [karama.id]);
if (observer && observerCard)
assert.deepEqual(richeseHomeworldShipmentPlayer(game, observer).hand.map(card => card.id), [observerCard.id]);
assert.equal(richeseHomeworldShipmentPlayer(game, owner).reserves, 20);
assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).forces, {});
const originalInventory = richeseHomeworldShipmentInventory(game); assert.equal(originalInventory.length, 45);
const originalNexusInventory = richeseHomeworldShipmentNexusInventory(game);
assert.deepEqual(originalNexusInventory, nexus ? [...NEXUS_FACTIONS].sort() : []);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3, 4, 5].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
@@ -137,14 +163,55 @@ export function createRicheseHomeworldShipmentFixture(options: {
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
const beforeFunding = structuredClone(game), funding: RicheseHomeworldShipmentTransition[] = [];
let beforeWorm: Game | null = null, beforeNexusDraw: Game | null = null;
let nexusAlliance: RicheseHomeworldShipmentTransition | null = null, nexusDraw: RicheseHomeworldShipmentTransition | null = null;
let wormOrder: { position: number; from: number; card: Game['spiceDeck'][number] } | null = null;
let nexusOrder: { position: number; from: number; card: 'richese' } | null = null;
const wormFollowupOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const reachOwnTurn = (turn: number) => {
if (nexus && !nexusDraw) {
assert.ok(observer);
reach(state => state.turn === 2 && state.phase === 1 && richeseHomeworldShipmentClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
beforeWorm = structuredClone(game);
const inventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const from = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(from >= 0); const worm = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(worm);
wormOrder = { position: 0, from, card: structuredClone(worm) };
for (const position of [1, 2]) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
wormFollowupOrder.push({ position, from, card: structuredClone(card) });
}
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), inventory);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && richeseHomeworldShipmentClean(state));
nexusAlliance = transition(() => {
act(holder, { type: 'alliance', target: observer }); act(observer, { type: 'alliance', target: holder });
});
assert.equal(richeseHomeworldShipmentPlayer(game, holder).ally, observer);
assert.equal(richeseHomeworldShipmentPlayer(game, observer).ally, holder);
assert.equal(richeseHomeworldShipmentPlayer(game, owner).ally, null);
reach(state => state.nexusCards?.phase?.stage === 'drawing' && richeseHomeworldShipmentClean(state));
const cards = game.nexusCards!.cards!, cardIndex = cards.deck.indexOf('richese'); assert.ok(cardIndex >= 0);
assert.equal(cards.hands[owner], null);
cards.deck.unshift(cards.deck.splice(cardIndex, 1)[0]);
nexusOrder = { position: 0, from: cardIndex, card: 'richese' };
beforeNexusDraw = structuredClone(game);
nexusDraw = transition(() => { act(owner, { type: 'nexusCardChoice', turn: game.turn,
card: null, choice: 'draw', ownRedraws: 0 }); });
assert.equal(game.nexusCards!.cards!.hands[owner], 'richese');
}
reach(state => state.turn === turn && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
};
for (let count = 0; richeseHomeworldShipmentPlayer(game, owner).spice < cost && count < 3; count++) {
const donor = richeseHomeworldShipmentPlayer(game, holder);
const amount = Math.min(cost - richeseHomeworldShipmentPlayer(game, owner).spice, donor.spice);
assert.ok(amount > 0, 'Actual Emperor income must fund the next deferred bribe');
funding.push(transition(() => { act(holder, { type: 'bribe', target: owner, amount }); }));
const turn = game.turn;
reach(state => state.turn === turn + 1 && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
reachOwnTurn(turn + 1);
}
if (nexus && !nexusDraw) reachOwnTurn(2);
assert.ok(richeseHomeworldShipmentPlayer(game, owner).spice >= cost);
const player = richeseHomeworldShipmentPlayer(game, owner);
assert.equal(player.reserves, 20); assert.deepEqual(player.forces, {}); assert.equal(player.tanks, 0);
@@ -152,6 +219,8 @@ export function createRicheseHomeworldShipmentFixture(options: {
assert.equal(richeseHomeworldShipmentPopulation(game, owner).side, 'high');
assert.ok(richeseHomeworldShipmentPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(richeseHomeworldShipmentInventory(game), originalInventory);
assert.deepEqual(richeseHomeworldShipmentNexusInventory(game), originalNexusInventory);
if (nexus) assert.equal(game.nexusCards!.cards!.hands[owner], 'richese');
const token = player.noField!.tokens.find(token => token.value === value); assert.ok(token);
const event = player.noFieldEvent; assert.ok(event);
const action: Action = { type: 'ship', noField: token.id, event, noFieldReserves: companions,
@@ -159,22 +228,33 @@ export function createRicheseHomeworldShipmentFixture(options: {
const cancel: Action = { type: 'card', card: karama.id, mode: 'cancel' };
const replacement: Action = { type: 'ship', amount: 1, elite: 0, territory, sector, allyPayment: 0,
homeworldSources: { 'homeworld:richese': { normal: 1, elite: 0 } } };
return { game, source: structuredClone(game), beforeShipment: structuredClone(game), initial, enableHomeworlds,
return { game, source: structuredClone(game), beforeShipment: structuredClone(game), initial, enableHomeworlds, enableNexus,
lobby, setup, beforeStartingDeal, afterSetup, beforeFunding, funding,
advanced, owner, richese: owner, actor: owner, holder, emperor: holder,
beforeWorm: beforeWorm as Game | null,
wormOrder: wormOrder as { position: number; from: number; card: Game['spiceDeck'][number] } | null,
wormFollowupOrder,
nexusAlliance: nexusAlliance as RicheseHomeworldShipmentTransition | null,
beforeNexusDraw: beforeNexusDraw as Game | null,
nexusDraw: nexusDraw as RicheseHomeworldShipmentTransition | null,
nexusOrder: nexusOrder as { position: number; from: number; card: 'richese' } | null,
advanced, nexus, owner, richese: owner, actor: owner, holder, emperor: holder, observer, atreides: observer,
companions, noFieldReserves: companions, amount: companions, physical: companions, priced: companions + 1,
cost, shipmentCost: cost, value, markerValue: value, token: token.id, tokenId: token.id, event, shipmentEvent: event,
territory, sector, location, action, shipmentAction: action,
shipmentStep: { actor: owner, action } satisfies RicheseHomeworldShipmentStep,
karama: karama.id, card: karama.id, cancel, cancelAction: cancel, replacement,
ordinary: ordinary.id, originalNoField, noField: structuredClone(player.noField),
originalInventory, originalCards: originalInventory, originalMainDeck, originalCache, originalSpice, deal, dealOrder, spiceOrder,
originalInventory, originalCards: originalInventory, originalMainDeck, originalCache, originalSpice,
originalNexusInventory, deal, dealOrder, spiceOrder,
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public Basic/Advanced Richese/Emperor lobby enables only Homeworlds, then native ready/start with original main35/cache10 and all three native No-Field components. Only original scalar lotteries, two unopened Treachery positions and six unopened ordinary Spice positions are selected.',
nexus
? 'Actual public Basic/Advanced Richese/Emperor/Atreides lobby enables Homeworlds and Nexus Cards, then native ready/start with original main35/cache10, all12 Nexus singletons and all three native No-Field components. Only original scalar lotteries, three unopened Treachery positions and six unopened ordinary Spice positions are selected.'
: 'Actual public Basic/Advanced Richese/Emperor lobby enables only Homeworlds, then native ready/start with original main35/cache10 and all three native No-Field components. Only original scalar lotteries, two unopened Treachery positions and six unopened ordinary Spice positions are selected.',
'Richese really receives an ordinary projectile and Emperor its printed original Karama. Every original Storm, Spice, Charity, native cache/normal auction, Black Market decline in Advanced, Revival, Movement ending and Mentat control is used. Real cache lots are passed and retired, never replaced or acquired by assigning a hand.',
'The default eleven-companion stronghold invoice needs twelve spice. Emperor really pays Richese a seven-spice bribe during turn one; its pending recipient share becomes available only at the actual Mentat. Richese’s next unused turn retains all twenty original reserves and HIGH population before declaration.',
'The sixteen-companion cap variant uses an actual ten-spice first bribe, next-turn Emperor Charity for two and a second two-spice bribe, both completed through real Mentat. Its third unused turn has seventeen spendable spice and the same untouched original twenty reserves. Smaller companion requests use only the native funding they need.',
...(nexus ? ['The original turn-two unopened Spice order supplies a real Shai-Hulud and ordinary land follow-ups. Emperor actually proposes and Atreides accepts alliance; Richese stays unallied. Only the unopened Richese Nexus singleton is moved to the next original draw position, then Richese really draws it at the closing Nexus choice. Two seats cannot earn that unallied draw; the native third seat supplies its real eligibility. Empty Carthag11 avoids Atreides’ original Arrakeen army.'] : []),
'Stop BEFORE the marker/physical shipment. The original token/event, opponent-held Karama, quiet unused shipment and exact funded combined invoice remain live caller inputs. No force, wallet, phase, hand, stance, token custody, canceled history or pending proof is installed.',
],
};
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { presenceAt } from '../game/force-presence';
import { territory } from '../game/board';
import { reserveShipmentCost } from '../game/shipment-price';
import { quoteRicheseHomeworldPair } from '../game/richese-homeworld-shipment';
import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options';
import {
createRicheseHomeworldCunningFixture as fixture,
actRicheseHomeworldCunning as act,
commitRicheseHomeworldCunning as commit,
revealRicheseHomeworldCunning as reveal,
finishRicheseHomeworldCunning as finish,
richeseHomeworldCunningPlayer as player,
richeseHomeworldCunningInventory as inventory,
richeseHomeworldCunningNexusInventory as nexusInventory,
richeseHomeworldCunningPopulation as population,
} from './fixture-richese-homeworld-cunning';
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native HIGH Cunning pays for eleven companions before its immediate five and retains a concealed three`, () => {
const f = fixture({ advanced }); assert.ok(f.observer && f.nexusAlliance && f.nexusDraw);
const before = structuredClone(player(f.game, f.owner));
assert.equal(f.game.turn, 2); assert.equal(f.game.active, f.owner);
assert.equal(player(f.game, f.holder).ally, f.observer);
assert.equal(player(f.game, f.observer).ally, f.holder);
assert.equal(before.ally, null); assert.equal(before.reserves, 20); assert.equal(before.spice, 12);
assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'richese');
assert.equal(f.nexusDraw.steps[0].action.type, 'nexusCardChoice');
assert.equal(f.nexusDraw.steps[0].action.choice, 'draw');
assert.equal(viewGame(f.game, f.owner).richeseNoField?.homeworldForcesEnabled, true);
assert.deepEqual(f.pairQuote, { paid: 11, physical: 16, priced: 12, revealed: 5 });
const action: Action = f.action;
const result = commit(f.game, f, action), requested = player(result.requested, f.owner), after = player(result.game, f.owner);
assert.equal(result.requested.response?.kind, 'richeseNoField');
assert.equal(result.requested.pendingShipment?.amount, 1);
assert.equal(result.requested.pendingShipment?.noFieldReserves, 11);
assert.equal(result.requested.pendingShipment?.richesePair?.revealedTokenId, f.revealedToken);
assert.equal(result.requested.pendingShipment?.noFieldSkillProof, undefined, 'The original pair frame owns its optional physical group');
assert.equal(result.requested.pendingShipment?.cost, 12);
assert.equal(requested.reserves, 20); assert.deepEqual(requested.forces, {}); assert.equal(requested.spice, 12);
assert.deepEqual(requested.noField, before.noField); assert.equal(requested.shipped, false);
assert.equal(result.requested.nexusCards!.cards!.hands[f.owner], null);
assert.equal(result.requested.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(result.requested.nexusRicheseCunningLast?.stage, 'pending');
assert.equal(after.reserves, 4); assert.deepEqual(after.forces, { [f.location]: 16 });
assert.equal(after.spice, 0); assert.equal(after.shipped, true); assert.equal(after.moved, 0);
assert.equal(after.noField?.deployed?.tokenId, f.concealedToken);
assert.equal(after.noField?.lastShipped, f.concealedToken);
assert.equal(presenceAt(after, f.territory), 17);
assert.equal(population(result.game).side, 'low');
assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped');
assert.equal(result.game.nexusRicheseCunningHistory?.length, 1);
const frame = JSON.parse(result.game.nexusRicheseCunningHistory![0].frame) as { noFieldReserves?: number };
assert.equal(frame.noFieldReserves, 11);
assert.equal(viewGame(result.game, f.holder).richeseNoField?.private, null);
assert.equal(viewGame(result.game, f.holder).richeseNoField?.public.lastUsed, null);
assert.ok(player(result.game, f.holder).hand.some(card => card.id === f.karama));
const revealed = reveal(result.game, f), done = player(revealed.game, f.owner);
assert.equal(done.reserves, 1); assert.deepEqual(done.forces, { [f.location]: 19 });
assert.equal(done.noField?.deployed, null); assert.equal(done.noField?.lastShipped, f.concealedToken);
assert.equal(done.spice, 0); assert.equal(done.moved, 0);
assert.deepEqual(inventory(revealed.game), f.originalInventory);
assert.deepEqual(nexusInventory(revealed.game), f.originalNexusInventory);
assert.equal(f.sourceActionCount, f.actions.length);
});
}
void test('sixteen genuine paid companions reserve stock before the immediate five; the concealed three later reveals zero', () => {
const f = fixture({ companions: 16 });
assert.equal(f.game.turn, 3);
assert.deepEqual(f.funding.map(record => record.steps[0].action.amount), [10, 2]);
assert.equal(player(f.game, f.owner).reserves, 20);
assert.equal(player(f.game, f.owner).spice, 17);
assert.deepEqual(f.pairQuote, { paid: 16, physical: 20, priced: 17, revealed: 4 });
const result = commit(f.game, f), after = player(result.game, f.owner);
assert.equal(after.spice, 0); assert.equal(after.reserves, 0);
assert.deepEqual(after.forces, { [f.location]: 20 }); assert.equal(after.tanks, 0);
assert.equal(after.noField?.deployed?.tokenId, f.concealedToken);
const revealed = reveal(result.game, f), done = player(revealed.game, f.owner);
assert.equal(done.reserves, 0); assert.deepEqual(done.forces, after.forces);
assert.equal(done.noField?.deployed, null); assert.equal(done.noField?.lastShipped, f.concealedToken);
assert.equal(done.spice, 0); assert.equal(done.tanks, 0);
assert.equal(revealed.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.deepEqual(inventory(revealed.game), f.originalInventory);
assert.deepEqual(nexusInventory(revealed.game), f.originalNexusInventory);
});
void test('the original Emperor Karama prevents the whole unpaid pair and paid group while its declared Nexus stays spent', () => {
const f = fixture(), before = structuredClone(player(f.game, f.owner));
const pending = act(f.game, f.owner, f.action);
assert.equal(pending.response?.kind, 'richeseNoField');
assert.equal(pending.nexusCards!.cards!.hands[f.owner], null);
const stopped = finish(act(pending, f.holder, f.cancel)), after = player(stopped, f.owner);
assert.equal(after.reserves, before.reserves); assert.deepEqual(after.forces, before.forces);
assert.equal(after.spice, before.spice); assert.equal(after.shipped, false); assert.equal(after.moved, 0);
assert.deepEqual(after.noField, before.noField); assert.equal(after.noFieldEvent, before.noFieldEvent);
assert.equal(stopped.pendingShipment ?? null, null);
assert.equal(stopped.nexusRicheseCunningLast?.stage, 'stopped');
assert.equal(stopped.nexusCards!.cards!.hands[f.owner], null);
assert.equal(stopped.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(viewGame(stopped, f.owner).nexusRicheseCunning, null);
const replacement = finish(act(stopped, f.owner, f.replacement)), replaced = player(replacement, f.owner);
assert.equal(replaced.reserves, 19); assert.deepEqual(replaced.forces, { [f.location]: 1 });
assert.equal(replaced.spice, before.spice - 1); assert.equal(replaced.shipped, true);
assert.deepEqual(replaced.noField, before.noField);
assert.deepEqual(inventory(replacement), f.originalInventory);
assert.deepEqual(nexusInventory(replacement), f.originalNexusInventory);
});
void test('omitting the new reserve count preserves an original native pair priced as one with only its immediate token materialized', () => {
const f = fixture();
const legacy: Action = { type: 'ship', noField: f.concealedToken, event: f.event, revealedToken: f.revealedToken,
nexus: f.nexusEvent, territory: f.territory, sector: f.sector, allyPayment: 0 };
const result = commit(f.game, f, legacy), after = player(result.game, f.owner);
assert.equal(result.requested.pendingShipment?.noFieldReserves, undefined);
assert.equal(result.requested.pendingShipment?.noFieldSkillProof, undefined);
assert.equal(result.requested.pendingShipment?.cost, 1);
assert.equal(after.spice, player(f.game, f.owner).spice - 1);
assert.equal(after.reserves, 15); assert.deepEqual(after.forces, { [f.location]: 5 });
assert.equal(after.noField?.deployed?.tokenId, f.concealedToken);
assert.equal(after.noField?.lastShipped, f.concealedToken);
assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped');
assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.deepEqual(inventory(result.game), f.originalInventory);
assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory);
});
void test('all four existing profiles retain a legal positive paid-group pair from the original Cunning offer', () => {
const f = fixture();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'ship' && action.revealedToken &&
action.nexus === f.nexusEvent && typeof action.noFieldReserves === 'number' && action.noFieldReserves > 0);
assert.ok(action, `${difficulty} has a minimal positive paid Cunning companion path`);
const token = player(f.game, f.owner).noField!.tokens.find(token => token.id === action.revealedToken); assert.ok(token);
const quote = quoteRicheseHomeworldPair({ enabled: true, reserves: 20, amount: Number(action.noFieldReserves), revealedValue: token.value });
const price = reserveShipmentCost({ faction: 'richese', halfRate: false }, territory(String(action.territory)).type, quote.priced);
const result = commit(structuredClone(f.game), f, action), after = player(result.game, f.owner);
assert.equal(after.reserves, 20 - quote.physical);
assert.equal(Object.values(after.forces).reduce((total, count) => total + count, 0), quote.physical);
assert.equal(after.spice, player(f.game, f.owner).spice - price);
assert.equal(after.noField?.deployed?.tokenId, action.noField);
assert.equal(after.shipped, true); assert.equal(after.moved, 0);
assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped');
assert.deepEqual(inventory(result.game), f.originalInventory);
assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory);
}
});
void test('the shared AI payment guard admits the actual funded pair and rejects an unfunded paid group', () => {
const f = fixture(), view = viewGame(f.game, f.owner), before = structuredClone(view);
assert.equal(botHomeworldShipmentPaymentAllowed(view, f.action), true);
assert.equal(botHomeworldShipmentPaymentAllowed(view, { ...f.action, noFieldReserves: 16 }), false,
'Twenty available counters cannot fund a seventeen-spice invoice from the actual twelve-spice wallet');
assert.deepEqual(view, before);
});
+51 -2
View File
@@ -4,8 +4,8 @@ import { newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody, quoteHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, RicheseHomeworldShipmentError,
type RicheseHomeworldForcesInput,
richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, quoteRicheseHomeworldPair,
RicheseHomeworldShipmentError, type RicheseHomeworldForcesInput, type RicheseHomeworldPairInput,
} from '../game/richese-homeworld-shipment';
/** Controlled native population context, not an earned shipment chronology. */
@@ -75,3 +75,52 @@ void test('malformed physical counts and source flags reject before producing a
assert.throws(() => quoteRicheseHomeworldForces(shipment({ enabled: 'yes' as unknown as boolean })), RicheseHomeworldShipmentError);
assert.throws(() => quoteRicheseHomeworldForces(shipment({ smuggler: 1 as unknown as boolean })), RicheseHomeworldShipmentError);
});
void test('public pair reserves paid counters before materializing a scarce revealed denomination', () => {
const input: RicheseHomeworldPairInput = { enabled: true, reserves: 5, amount: 4, revealedValue: 5 };
const before = structuredClone(input);
assert.deepEqual(quoteRicheseHomeworldPair(input), { paid: 4, physical: 5, priced: 5, revealed: 1 });
assert.deepEqual(input, before);
assert.deepEqual(quoteRicheseHomeworldPair({ enabled: true, reserves: 3, amount: 3, revealedValue: 3 }),
{ paid: 3, physical: 3, priced: 4, revealed: 0 });
});
void test('public zero, three and five affect physical reveal without changing the combined paid invoice', () => {
for (const revealedValue of [0, 3, 5] as const) {
const input = { enabled: true, reserves: 10, amount: 2, revealedValue };
const before = structuredClone(input);
assert.deepEqual(quoteRicheseHomeworldPair(input),
{ paid: 2, physical: 2 + revealedValue, priced: 3, revealed: revealedValue });
assert.deepEqual(input, before);
}
});
void test('ordinary zero-paid public pairs need no High benefit and reveal only actual remaining stock', () => {
for (const revealedValue of [0, 3, 5] as const) {
const input = { enabled: false, reserves: 2, amount: 0, revealedValue };
const revealed = Math.min(2, revealedValue);
assert.deepEqual(quoteRicheseHomeworldPair(input), { paid: 0, physical: revealed, priced: 1, revealed });
assert.deepEqual(quoteRicheseHomeworldPair({ ...input, reserves: 0 }),
{ paid: 0, physical: 0, priced: 1, revealed: 0 });
}
assert.throws(() => quoteRicheseHomeworldPair({ enabled: false, reserves: 10, amount: 1, revealedValue: 3 }), /High population/);
assert.throws(() => quoteRicheseHomeworldPair({ enabled: true, reserves: 2, amount: 3, revealedValue: 5 }), /physical reserves/);
});
void test('public pair rejects invalid denominations and reuses safe physical count validation without mutation', () => {
const invalid: unknown[] = [-1, 1, 2, 4, 6, 3.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '3', null, undefined];
for (const value of invalid) {
const input: RicheseHomeworldPairInput = {
enabled: true, reserves: 10, amount: 2, revealedValue: value as RicheseHomeworldPairInput['revealedValue'],
};
const before = structuredClone(input);
assert.throws(() => quoteRicheseHomeworldPair(input), RicheseHomeworldShipmentError);
assert.deepEqual(input, before);
}
for (const field of ['reserves', 'amount'] as const) for (const value of [-1, 0.5, 21, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const input: RicheseHomeworldPairInput = { enabled: true, reserves: 10, amount: 2, revealedValue: 3, [field]: value };
const before = structuredClone(input);
assert.throws(() => quoteRicheseHomeworldPair(input), RicheseHomeworldShipmentError);
assert.deepEqual(input, before);
}
});