Implement High Richese mixed No-Field shipments

This commit is contained in:
Quintine committed 2026-10-25 19:56:38 +11:00
1 parent 052737d0f9
commit 5bcf5bab48
16 files changed
+912 -54

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { NoFieldValue } from '../game/richese-no-field';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
export type RicheseHomeworldShipmentStep = TruthMovementStep;
export type RicheseHomeworldShipmentTransition = { before: Game; steps: RicheseHomeworldShipmentStep[]; after: Game };
export const actRicheseHomeworldShipment = actTruthMovement;
export const richeseHomeworldShipmentPlayer = (game: Game, actor = 'richese') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const richeseHomeworldShipmentClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function richeseHomeworldShipmentInventory(game: Game) {
homeworldGameIntegrity(game);
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((total, count) => total + count, 0);
for (const player of game.players) {
const visitors = Object.values(game.homeworlds!.custody!.visitors)
.map(groups => groups[player.id] ?? { normal: 0, elite: 0 });
assert.equal(player.reserves + player.tanks + sum(player.forces) +
visitors.reduce((total, group) => total + group.normal + group.elite, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
visitors.reduce((total, group) => total + group.elite, 0), 5);
}
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? []),
...game.players.flatMap(player => player.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Every original main/cache card retains one physical slot');
return ids;
}
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;
};
/** 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 (richeseHomeworldShipmentClean(game)) {
if (game.status === 'playing' && game.phase === 2) {
const emperor = richeseHomeworldShipmentPlayer(game, 'emperor');
if (emperor.spice < 2 && emperor.charityTurn !== game.turn)
return { actor: emperor.id, action: { type: 'charity' } };
}
if (game.status === 'playing' && game.phase === 5 && game.active === 'emperor')
return { actor: 'emperor', action: { type: 'endMovement' } };
}
return nextTruthMovementNativeEarlyStep(game, {
position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false,
card: game.richeseCache?.[0]?.id,
});
}
export const nextRicheseHomeworldShipmentNativeStep = nextRicheseHomeworldShipment;
export function advanceRicheseHomeworldShipment(game: Game, until: (state: Game) => boolean,
actions: RicheseHomeworldShipmentStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Richese programme ended before its requested control');
const next = nextRicheseHomeworldShipment(game);
assert.ok(next, `Supply the original owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actRicheseHomeworldShipment(game, next.actor, next.action, actions);
}
throw Error('The genuine Richese Homeworld shipment source did not reach its finite boundary');
}
export function passRicheseHomeworldShipment(game: Game, actions: RicheseHomeworldShipmentStep[] = []): Game {
for (let count = 0; game.response && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actRicheseHomeworldShipment(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response); return game;
}
export function finishRicheseHomeworldShipment(game: Game, actions: RicheseHomeworldShipmentStep[] = []): Game {
return advanceRicheseHomeworldShipment(game, richeseHomeworldShipmentClean, actions);
}
/** Actual public Basic/Advanced Richese/Emperor with Homeworlds, original35+10
* 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;
} = {}) {
const advanced = options.advanced ?? false, companions = options.companions ?? 11, value = options.value ?? 5;
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 cost = 1 + companions;
let game = createGame('RICHESEHOMEWORLDSHIPMENT', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(holder, 'Emperor', 'emperor'));
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); };
const transition = (run: () => void): RicheseHomeworldShipmentTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
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 &&
!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 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]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), originalNoField = structuredClone(richeseHomeworldShipmentPlayer(game, owner).noField);
assert.ok(originalNoField);
assert.deepEqual(originalNoField.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
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]);
assert.equal(richeseHomeworldShipmentPlayer(game, owner).reserves, 20);
assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).forces, {});
const originalInventory = richeseHomeworldShipmentInventory(game); assert.equal(originalInventory.length, 45);
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);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
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[] = [];
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));
}
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);
assert.equal(player.bribes, 0); assert.equal(player.shipped, false); assert.equal(player.moved, 0);
assert.equal(richeseHomeworldShipmentPopulation(game, owner).side, 'high');
assert.ok(richeseHomeworldShipmentPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(richeseHomeworldShipmentInventory(game), originalInventory);
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,
territory, sector, allyPayment: 0 };
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,
lobby, setup, beforeStartingDeal, afterSetup, beforeFunding, funding,
advanced, owner, richese: owner, actor: owner, holder, emperor: holder,
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,
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.',
'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.',
'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.',
],
};
}
export type RicheseHomeworldShipmentFixture = ReturnType<typeof createRicheseHomeworldShipmentFixture>;
export function commitRicheseHomeworldShipment(source: Game, fixture: RicheseHomeworldShipmentFixture,
action: Action = fixture.action, actions: RicheseHomeworldShipmentStep[] = []) {
const before = structuredClone(source), start = actions.length;
let game = actRicheseHomeworldShipment(source, fixture.owner, action, actions);
const requested = structuredClone(game);
game = finishRicheseHomeworldShipment(game, actions);
return { game, before, requested, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
}
export function revealRicheseHomeworldShipment(source: Game, fixture: RicheseHomeworldShipmentFixture,
actions: RicheseHomeworldShipmentStep[] = []) {
const before = structuredClone(source), start = actions.length, player = richeseHomeworldShipmentPlayer(source, fixture.owner);
assert.ok(player.noField?.deployed && player.noFieldEvent);
const action: Action = { type: 'revealNoField', token: player.noField.deployed.tokenId, event: player.noFieldEvent };
let game = actRicheseHomeworldShipment(source, fixture.owner, action, actions);
game = finishRicheseHomeworldShipment(game, actions);
return { game, before, after: structuredClone(game), action, steps: structuredClone(actions.slice(start)) };
}
+2 -2
View File
@@ -98,7 +98,7 @@ void test('restoration rejects an orphaned pair before it can lose its Karama or
assert.equal(declared.response?.kind, 'richeseNoField');
const missingResponse = JSON.parse(JSON.stringify(declared)) as Game;
missingResponse.response = null;
assert.throws(() => normalizeAutomaticGame(missingResponse), /Karama response or Guild interception/);
assert.throws(() => normalizeAutomaticGame(missingResponse), /unique Karama, native Guild interception or funded invoice continuation/);
const advanced = nexusRicheseFixture({
ownerFaction: 'richese', opponentFaction: 'guild', advanced: true,
@@ -109,7 +109,7 @@ void test('restoration rejects an orphaned pair before it can lose its Karama or
assert.equal(pending.decision?.kind, 'guildShipment');
const missingDecision = JSON.parse(JSON.stringify(pending)) as Game;
missingDecision.decision = null;
assert.throws(() => viewGame(missingDecision, advanced.owner), /Karama response or Guild interception/);
assert.throws(() => viewGame(missingDecision, advanced.owner), /unique Karama, native Guild interception or funded invoice continuation/);
});
void test('pair rejects duplicate, prior-used and stale tokens without changing the original game', () => {
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Children, createElement, isValidElement, type ChangeEvent, type InputHTMLAttributes, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { RicheseHomeworldForces, type RicheseHomeworldForcesProps } from '../components/richese-homeworld-forces';
/** Direct controlled props and actual callbacks; no browser or native engine
* provenance is claimed by these component-only tests. */
function props(patch: Partial<RicheseHomeworldForcesProps> = {}): RicheseHomeworldForcesProps {
return { enabled: true, amount: 3, max: 10, busy: false, onChange() {}, ...patch };
}
const markup = (value: RicheseHomeworldForcesProps) => renderToStaticMarkup(createElement(RicheseHomeworldForces, value));
function input(value: RicheseHomeworldForcesProps): ReactElement<InputHTMLAttributes<HTMLInputElement>> {
const queue: ReactNode[] = [RicheseHomeworldForces(value)];
while (queue.length) {
const node = queue.shift();
if (!isValidElement<{ children?: ReactNode }>(node)) continue;
if (node.type === 'input') return node as ReactElement<InputHTMLAttributes<HTMLInputElement>>;
queue.push(...Children.toArray(node.props.children));
}
throw Error('The persistent paid-force input is missing');
}
function change(field: ReactElement<InputHTMLAttributes<HTMLInputElement>>, value: string) {
assert.ok(field.props.onChange);
field.props.onChange({ target: { value } } as ChangeEvent<HTMLInputElement>);
}
void test('paid-force control is persistent, labelled and separates current forces from concealed revelation', () => {
const html = markup(props());
assert.match(html, /aria-label="Richese Homeworld shipment"/);
assert.match(html, /Paid reserve forces with No-Field/);
assert.match(html, /type="number"[^>]*min="0"[^>]*max="10"[^>]*step="1"/);
assert.match(html, /Paid forces enter the destination now/);
assert.match(html, /stay in reserves until revelation/);
assert.match(html, /reserves then available/);
assert.doesNotMatch(html, /timeout|countdown|automatically submit/i);
});
void test('actual numeric callback accepts bounded whole amounts including zero', () => {
const seen: number[] = [], field = input(props({ onChange: value => seen.push(value) }));
for (const value of ['4', '0', '10']) change(field, value);
for (const value of ['-1', '11', '1.5', 'not-a-number', 'Infinity']) change(field, value);
assert.deepEqual(seen, [4, 0, 10]);
});
void test('busy state disables the field and an already queued callback cannot change the selection', () => {
const seen: number[] = [], value = props({ busy: true, onChange: amount => seen.push(amount) });
const field = input(value);
assert.equal(field.props.disabled, true);
change(field, '0');
assert.deepEqual(seen, []);
assert.match(markup(value), /disabled=""/);
});
void test('lost High entitlement clearly blocks a positive selection while keeping zero selectable', () => {
const seen: number[] = [], value = props({ enabled: false, onChange: amount => seen.push(amount) });
const field = input(value);
assert.equal(field.props.disabled, false);
assert.equal(field.props['aria-invalid'], true);
change(field, '2'); change(field, '0');
assert.deepEqual(seen, [0]);
const html = markup(value);
assert.match(html, /require Richese at High population/);
assert.match(html, /Set the amount to 0/);
assert.match(markup(props({ enabled: false, amount: 0, max: 0 })), /type="number"/);
});
void test('a stale amount above the current physical maximum stays visible with a correction path', () => {
const seen: number[] = [], value = props({ amount: 3, max: 1, onChange: amount => seen.push(amount) });
assert.match(markup(value), /Only 1 physical reserve force is available/);
change(input(value), '1');
assert.deepEqual(seen, [1]);
});
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame } from '../game/engine';
import { RicheseNoFieldControls } from '../components/richese-no-field';
import {
createRicheseHomeworldShipmentFixture as fixture,
actRicheseHomeworldShipment as act,
commitRicheseHomeworldShipment as commit,
richeseHomeworldShipmentInventory as inventory,
richeseHomeworldShipmentPlayer as player,
} from './fixture-richese-homeworld-shipment';
for (const companions of [1, 2]) void test(`original Karama prices marker plus ${companions} paid companions as one rounded group`, () => {
const f = fixture({ companions });
const rated = act(f.game, f.holder, { type: 'card', card: f.karama, mode: 'shipment', target: f.owner });
assert.equal(rated.karamaShipping?.player, f.owner);
assert.ok(rated.discard.some(card => card.id === f.karama));
const source = structuredClone(rated), original = player(rated);
const result = commit(rated, f), done = player(result.game);
const expected = companions === 1 ? 1 : 2;
// The only opposing Karama was just spent, so native automatic passes may
// finish the response during the declaration itself.
assert.equal(result.requested.pendingShipment ?? null, null);
assert.equal(original.spice - done.spice, expected);
assert.equal(original.reserves - done.reserves, companions);
assert.equal(done.forces[f.location], companions);
assert.equal(done.noField?.deployed?.tokenId, f.token);
assert.equal(result.game.karamaShipping, null);
assert.equal(viewGame(result.game, f.holder).richeseNoField?.private, null);
assert.deepEqual(rated, source);
assert.deepEqual(inventory(result.game), f.originalInventory);
});
void test('controlled ally/rate projection shows the full replacement fee after native discount cancellation', () => {
const f = fixture({ companions: 1 }), view = viewGame(f.game, f.owner);
// Projection-only UI regression, not an earned Guild alliance/cancellation.
const owner = view.players.find(p => p.id === f.owner)!, ally = view.players.find(p => p.id === f.holder)!;
owner.ally = ally.id; ally.ally = owner.id; ally.faction = 'guild';
const markup = () => renderToStaticMarkup(createElement(RicheseNoFieldControls,
{ game: view, act() {}, busy: false, destination: 'hagga_basin', sector: 8, allyPayment: '' }));
view.guildRateCanceled = true;
assert.match(markup(), /Ship concealed No-Field [035] · 2 spice/);
view.guildRateCanceled = false;
assert.match(markup(), /Ship concealed No-Field [035] · 1 spice/);
view.guildRateCanceled = true;
view.karamaShipping = { player: f.owner, owner: f.holder };
assert.match(markup(), /Ship concealed No-Field [035] · 1 spice/);
});
@@ -0,0 +1,155 @@
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 { DIFFICULTIES } from '../game/bot-profiles';
import { presenceAt } from '../game/force-presence';
import { reserveShipmentCost } from '../game/shipment-price';
import { territory } from '../game/board';
import { quoteRicheseHomeworldForces } from '../game/richese-homeworld-shipment';
import {
createRicheseHomeworldShipmentFixture as fixture,
actRicheseHomeworldShipment as act,
commitRicheseHomeworldShipment as commit,
revealRicheseHomeworldShipment as reveal,
finishRicheseHomeworldShipment as finish,
richeseHomeworldShipmentPlayer as player,
richeseHomeworldShipmentPopulation as population,
richeseHomeworldShipmentInventory as inventory,
} from './fixture-richese-homeworld-shipment';
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
const before = structuredClone(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native HIGH Richese commits eleven paid reserves and one concealed marker, then may reveal after crossing LOW`, () => {
const f = fixture({ advanced }), before = structuredClone(player(f.game, f.owner));
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.owner);
assert.equal(f.funding.length, 1);
assert.deepEqual(f.funding[0].steps, [{ actor: f.holder, action: { type: 'bribe', target: f.owner, amount: 7 } }]);
assert.equal(before.spice, 12); assert.equal(before.bribes, 0); assert.equal(before.reserves, 20);
assert.equal(before.shipped, false); assert.equal(before.moved, 0);
assert.equal(population(f.game, f.owner).side, 'high');
assert.equal(viewGame(f.game, f.owner).richeseNoField?.homeworldForcesEnabled, true);
assert.deepEqual(quoteRicheseHomeworldForces({ enabled: true, reserves: before.reserves, amount: f.companions }),
{ paid: 11, physical: 11, priced: 12 });
const action: Action = advanced ? { ...f.action, amount: 1 } : 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?.noFieldReserves, 11);
assert.equal(result.requested.pendingShipment?.cost, 12);
assert.ok(result.requested.pendingShipment?.noFieldSkillProof);
assert.equal(requested.reserves, before.reserves); assert.equal(requested.spice, before.spice);
assert.equal(requested.shipped, false); assert.deepEqual(requested.forces, {});
assert.deepEqual(requested.noField, before.noField); assert.equal(requested.noFieldEvent, before.noFieldEvent);
assert.equal(after.reserves, 9); assert.deepEqual(after.forces, { [f.location]: 11 });
assert.equal(after.spice, 0); assert.equal(after.shipped, true); assert.equal(after.moved, 0);
assert.equal(after.noField?.deployed?.tokenId, f.token);
assert.deepEqual(after.noField?.deployed?.location, { territory: f.territory, sector: f.sector });
assert.equal(after.noField?.lastShipped, f.token);
assert.notEqual(after.noFieldEvent, before.noFieldEvent);
assert.equal(presenceAt(after, f.territory), 12, 'The marker counts once beside eleven physical counters');
assert.equal(population(result.game, f.owner).side, 'low');
assert.equal(viewGame(result.game, f.owner).richeseNoField?.homeworldForcesEnabled, false);
const opponent = viewGame(result.game, f.holder);
assert.equal(opponent.richeseNoField?.private, null);
assert.equal(opponent.richeseNoField?.public.deployed?.effectiveForces, 1);
assert.equal(opponent.richeseNoField?.public.lastUsed, null);
assert.ok(player(result.game, f.holder).hand.some(card => card.id === f.karama));
assert.deepEqual(inventory(result.game), f.originalInventory);
const revealed = reveal(result.game, f), done = player(revealed.game, f.owner);
assert.equal(done.noField?.deployed, null); assert.equal(done.noField?.lastShipped, f.token);
assert.equal(done.reserves, 4); assert.deepEqual(done.forces, { [f.location]: 16 });
assert.equal(done.spice, after.spice); assert.equal(done.moved, 0); assert.equal(done.shipped, true);
reject(revealed.game, f.owner, revealed.action, /No concealed/);
assert.deepEqual(inventory(revealed.game), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
});
}
void test('the original single-marker omission still pays one without withdrawing HIGH physical reserves', () => {
const f = fixture(), before = structuredClone(player(f.game, f.owner));
const action: Action = { type: 'ship', noField: f.token, event: f.event,
territory: f.territory, sector: f.sector, allyPayment: 0 };
const result = commit(f.game, f, action), after = player(result.game, f.owner);
assert.equal(result.requested.pendingShipment?.noFieldReserves, undefined);
assert.equal(after.spice, before.spice - 1);
assert.equal(after.reserves, 20); assert.deepEqual(after.forces, {});
assert.equal(after.noField?.deployed?.tokenId, f.token);
assert.equal(presenceAt(after, f.territory), 1);
assert.equal(population(result.game, f.owner).side, 'high');
assert.deepEqual(inventory(result.game), f.originalInventory);
});
void test('sixteen genuine paid companions leave only four native reserves for a printed five marker to reveal', () => {
const f = fixture({ companions: 16 }), before = structuredClone(player(f.game, f.owner));
assert.equal(f.game.turn, 3);
assert.deepEqual(f.funding.map(record => record.steps[0].action.amount), [10, 2]);
assert.ok(f.actions.some(step => step.actor === f.holder && step.action.type === 'charity'));
assert.equal(before.spice, 17); assert.equal(before.reserves, 20);
const result = commit(f.game, f), after = player(result.game, f.owner);
assert.equal(after.reserves, 4); assert.deepEqual(after.forces, { [f.location]: 16 });
assert.equal(before.spice - after.spice, 17);
assert.equal(after.noField?.deployed?.tokenId, f.token);
const revealed = reveal(result.game, f), done = player(revealed.game, f.owner);
assert.equal(done.reserves, 0); assert.deepEqual(done.forces, { [f.location]: 20 });
assert.equal(done.tanks, 0); assert.equal(done.noField?.deployed, null);
assert.equal(done.noField?.lastShipped, f.token);
assert.deepEqual(inventory(revealed.game), f.originalInventory);
});
void test('the genuinely dealt opposing Karama prevents the entire unpaid mixed declaration and preserves ordinary replacement shipment', () => {
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.pendingShipment?.noFieldReserves, 11);
const prevented = finish(act(pending, f.holder, f.cancel)), after = player(prevented, f.owner);
assert.equal(after.reserves, before.reserves); assert.equal(after.spice, before.spice);
assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.noField, before.noField);
assert.equal(after.noFieldEvent, before.noFieldEvent);
assert.equal(after.shipped, false); assert.equal(after.moved, 0);
assert.equal(prevented.pendingShipment ?? null, null);
assert.equal(prevented.discard.filter(card => card.id === f.karama).length, 1);
reject(prevented, f.owner, f.action, /prevented No-Field/);
const replaced = finish(act(prevented, f.owner, f.replacement)), replacement = player(replaced, f.owner);
assert.equal(replacement.reserves, before.reserves - 1);
assert.deepEqual(replacement.forces, { [f.location]: 1 });
assert.equal(replacement.spice, before.spice - 1);
assert.equal(replacement.shipped, true); assert.equal(replacement.moved, 0);
assert.deepEqual(replacement.noField, before.noField);
assert.deepEqual(inventory(replaced), f.originalInventory);
});
void test('mixed payloads preserve the one-marker amount and require positive available physical reserves', () => {
const f = fixture();
reject(f.game, f.owner, { ...f.action, amount: 2 }, /one No-Field/);
reject(f.game, f.owner, { ...f.action, noFieldReserves: 0 });
reject(f.game, f.owner, { ...f.action, noFieldReserves: 21 });
reject(f.game, f.owner, { ...f.replacement, noFieldReserves: 1 });
assert.deepEqual(inventory(f.game), f.originalInventory);
});
void test('all four existing profiles propose a legal positive companion shipment through the native response and payment', () => {
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.noField &&
typeof action.noFieldReserves === 'number' && action.noFieldReserves > 0);
assert.ok(action, `${difficulty} retains a minimal positive HIGH companion path`);
const companions = Number(action.noFieldReserves);
const priced = 1 + companions;
const expectedCost = reserveShipmentCost({ faction: 'richese', halfRate: false },
territory(String(action.territory)).type, priced);
const result = commit(structuredClone(f.game), f, action), after = player(result.game, f.owner);
assert.equal(after.reserves, 20 - companions);
assert.equal(Object.values(after.forces).reduce((total, count) => total + count, 0), companions);
assert.equal(after.spice, player(f.game, f.owner).spice - expectedCost);
assert.equal(after.shipped, true); assert.equal(after.moved, 0);
assert.ok(after.noField?.deployed);
assert.deepEqual(inventory(result.game), f.originalInventory);
}
});
+77
View File
@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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,
} from '../game/richese-homeworld-shipment';
/** Controlled native population context, not an earned shipment chronology. */
function populationContext(population = 10, advanced = false): Pick<Game, 'advanced' | 'players' | 'homeworlds'> {
const players = [newPlayer('r', 'Richese', 'richese'), newPlayer('g', 'Guild', 'guild')];
players[0].reserves = population;
players[0].tanks = 20 - population;
return { advanced, players, homeworlds: { custody: createHomeworldCustody(homeworldContext({ advanced, players })) } };
}
void test('native Richese High enables mixed No-Field shipment at ten counters in both rules bands', () => {
for (const advanced of [false, true]) for (const population of [0, 9, 10, 11, 20]) {
const context = populationContext(population, advanced), before = structuredClone(context);
assert.equal(richeseHomeworldShipmentEnabled(context, 'r'), population >= 10);
assert.equal(richeseHomeworldShipmentEnabled(context, 'g'), false);
assert.equal(richeseHomeworldShipmentEnabled(context, 'missing'), false);
assert.deepEqual(context, before);
}
});
void test('missing custody and foreign visitors cannot supply the native Richese High entitlement', () => {
const context = populationContext(9);
assert.equal(richeseHomeworldShipmentEnabled({ ...context, homeworlds: undefined }, 'r'), false);
assert.equal(richeseHomeworldShipmentEnabled({ ...context, homeworlds: { custody: null } }, 'r'), false);
const visit = quoteHomeworldCustody(homeworldContext(context), context.homeworlds!.custody!, [
{ homeworld: 'homeworld:guild', player: 'g', withdraw: { normal: 8, elite: 0 }, deposit: { normal: 0, elite: 0 } },
{ homeworld: 'homeworld:richese', player: 'g', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 8, elite: 0 } },
]);
for (const next of visit.players) context.players.find(player => player.id === next.id)!.reserves = next.reserves;
context.homeworlds!.custody = visit.state;
assert.equal(richeseHomeworldShipmentEnabled(context, 'r'), false);
});
const shipment = (patch: Partial<RicheseHomeworldForcesInput> = {}): RicheseHomeworldForcesInput => ({
enabled: true, reserves: 10, amount: 3, ...patch,
});
void test('one marker and paid physical forces form one priced group independently of concealed value', () => {
const input = shipment(), before = structuredClone(input);
assert.deepEqual(quoteRicheseHomeworldForces(input), { paid: 3, physical: 3, priced: 4 });
assert.deepEqual(input, before);
assert.deepEqual(quoteRicheseHomeworldForces(shipment({ amount: 20, reserves: 20 })), { paid: 20, physical: 20, priced: 21 });
assert.deepEqual(quoteRicheseHomeworldForces(shipment({ amount: 0, reserves: 0 })), { paid: 0, physical: 0, priced: 1 });
});
void test('a Smuggler force consumes a real reserve counter without adding to the paid invoice', () => {
assert.deepEqual(quoteRicheseHomeworldForces(shipment({ smuggler: true })), { paid: 3, physical: 4, priced: 4 });
assert.deepEqual(quoteRicheseHomeworldForces(shipment({ amount: 19, reserves: 20, smuggler: true })), { paid: 19, physical: 20, priced: 20 });
assert.throws(() => quoteRicheseHomeworldForces(shipment({ reserves: 3, smuggler: true })), /physical reserves/);
assert.throws(() => quoteRicheseHomeworldForces(shipment({ amount: 20, reserves: 20, smuggler: true })), /physical reserves/);
});
void test('ordinary marker-only shipment remains available without the benefit but cannot manufacture forces', () => {
assert.deepEqual(quoteRicheseHomeworldForces(shipment({ enabled: false, amount: 0, reserves: 0 })), { paid: 0, physical: 0, priced: 1 });
assert.deepEqual(quoteRicheseHomeworldForces(shipment({ enabled: false, amount: 0, reserves: 1, smuggler: true })), { paid: 0, physical: 1, priced: 1 });
assert.throws(() => quoteRicheseHomeworldForces(shipment({ enabled: false })), /High population/);
assert.throws(() => quoteRicheseHomeworldForces(shipment({ reserves: 2 })), /physical reserves/);
assert.throws(() => quoteRicheseHomeworldForces(shipment({ amount: 0, reserves: 0, smuggler: true })), /physical reserves/);
});
void test('malformed physical counts and source flags reject before producing a quote', () => {
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 = shipment({ [field]: value }), before = structuredClone(input);
assert.throws(() => quoteRicheseHomeworldForces(input), RicheseHomeworldShipmentError);
assert.deepEqual(input, before);
}
assert.throws(() => quoteRicheseHomeworldForces(shipment({ enabled: 'yes' as unknown as boolean })), RicheseHomeworldShipmentError);
assert.throws(() => quoteRicheseHomeworldForces(shipment({ smuggler: 1 as unknown as boolean })), RicheseHomeworldShipmentError);
});