Prototype Richese No-Fields on foreign Homeworlds

This commit is contained in:
Quintine committed 2026-10-28 20:04:03 +11:00
1 parent dd3c199e2e
commit 850227c26d
71 files changed
+1806 -187

No files matched your search

+2 -1
View File
@@ -645,7 +645,8 @@ function normalize(g: Game): BattleResolutionInput {
faction: p.faction,
ally: p.ally,
noFieldAtTerritory:
p.noField?.deployed?.location.territory === b.territory,
!!p.noField?.deployed && 'territory' in p.noField.deployed.location &&
p.noField.deployed.location.territory === b.territory,
})),
physicalCards: [
...g.deck,
@@ -348,6 +348,7 @@ export function discoveryPairedNexusE2SkillsCunningAction(game: Game, richese: s
export function prepareDiscoveryPairedNexusE2SkillsBattle(f: DiscoveryPairedNexusE2SkillsFixture, shipped: Game,
trained = true): DiscoveryPairedNexusE2SkillsBattle {
const deployed = pairedSkillsPlayer(shipped, f.richese).noField!.deployed; assert.ok(deployed);
assert.ok('territory' in deployed.location);
const destination = deployed.location.territory, key = `${destination}:${deployed.location.sector}`;
let game = revealPairedSkillsRichese(shipped, f.richese, f.actions);
assert.equal(pairedSkillsPlayer(game, f.richese).noField!.deployed, null);
+3 -1
View File
@@ -76,8 +76,10 @@ function selectSpice(game: Game, position: number, worm = false): SpiceSelection
}
export function guildNoFieldTransportAction(game: Game, route: 'cross' | 'return' = 'cross'): Action {
const own = guildNoFieldTransportPlayer(game, 'richese'), marker = own.noField?.deployed; assert.ok(marker && own.noFieldEvent);
const markerLocation = marker.location;
assert.ok('territory' in markerLocation);
const forces = Object.fromEntries(Object.entries(own.forces).filter(([key, amount]) =>
amount > 0 && key.startsWith(`${marker.location.territory}:`)));
amount > 0 && key.startsWith(`${markerLocation.territory}:`)));
const nexus = viewGame(game, own.id).nexusGuildSecretAlly;
return { type: 'guildShip', noField: marker.tokenId, event: own.noFieldEvent, forces,
territory: route === 'return' ? 'reserves' : 'polar_sink', sector: 0, allyPayment: 0,
+1
View File
@@ -218,6 +218,7 @@ for (const level of DIFFICULTIES) {
assert.deepEqual(done.players[2].forces, {});
assert.equal(done.players[2].reserves, 20);
assert.deepEqual(done.players[2].noField!.tokens, p.noField.tokens);
assert.ok('territory' in done.players[2].noField!.deployed!.location);
assert.equal(
done.players[2].noField!.deployed!.location.territory,
a.territory,
+1
View File
@@ -271,6 +271,7 @@ void test('high Richese moves the same physical token once and never borrows con
population(g, 10, 'imperial_basin:10');
const action = marker(g);
const next = applyAction(reload(g), 'owner', action);
assert.ok('territory' in next.players[0].noField!.deployed!.location);
assert.equal(
next.players[0].noField!.deployed!.location.territory,
'arrakeen',
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import { mobileStrongholdCollectionCapacity, type MobileCollectionPlayer } from '../game/mobile-stronghold-collection';
void test('a Homeworld No-Field contributes nothing to a Mobile Stronghold collection', () => {
const ix: MobileCollectionPlayer = { id: 'i', faction: 'ixians', advisors: {}, forces: { [MOBILE_LOCATION]: 2 } };
const richese: MobileCollectionPlayer = { id: 'r', faction: 'richese', advisors: {}, forces: {},
noField: { deployed: { controller: 'r', location: { homeworld: 'homeworld:atreides' } } } };
assert.equal(mobileStrongholdCollectionCapacity([ix, richese]), 4);
assert.equal(mobileStrongholdCollectionCapacity([richese]), 0);
});
void test('an aboard No-Field continues to contribute one without reading its hidden face', () => {
const hidden = { deployed: { controller: 'r', location: { territory: MOBILE_STRONGHOLD, sector: 0 } },
get tokens(): never { throw new Error('The concealed No-Field face was read.'); } };
const richese: MobileCollectionPlayer = { id: 'r', faction: 'richese', advisors: {}, forces: {}, noField: hidden };
assert.equal(mobileStrongholdCollectionCapacity([richese]), 2);
});
+2 -1
View File
@@ -188,7 +188,8 @@ void test('pending marker recall binds exact private source while leaving denomi
const mutations: Array<(copy: Game) => void> = [
copy => { copy.players.find(p => p.id === ally)!.noFieldEvent = 'new-event'; },
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed = null; },
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed!.location.sector = 15; },
copy => { const location = copy.players.find(p => p.id === ally)!.noField!.deployed!.location;
assert.ok('territory' in location); location.sector = 15; },
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed!.controller = holder; },
copy => {
const state = copy.players.find(p => p.id === ally)!.noField!;
+1
View File
@@ -115,6 +115,7 @@ void test('irrelevant faces and hidden No-Field contents are not read at any pub
frame = answerNexusFremenProtection(frame, 'a', false);
assert.equal(frame.stage, 'complete');
validateNexusFremenProtection(input, frame);
assert.ok('territory' in marker.deployed!.location);
assert.equal(marker.deployed!.location.territory, 'hagga_basin');
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createHomeworldCustody, type HomeworldCustodyContext } from '../game/homeworld-custody';
import { homeworldAllianceBlock } from '../game/homeworld-alliance';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { applyAction, viewGame } from '../game/engine';
import { createRicheseHomeworldShipmentFixture, passRicheseHomeworldShipment } from './fixture-richese-homeworld-shipment';
function fixture() {
const context: HomeworldCustodyContext = { advanced: false, players: [
{ id: 'r-seat', faction: 'richese', reserves: 10, eliteReserves: 0 },
{ id: 'c-seat', faction: 'choam', reserves: 6, eliteReserves: 0 },
{ id: 'e-seat', faction: 'ecaz', reserves: 6, eliteReserves: 0 },
] };
const custody = createHomeworldCustody(context);
return { context, custody, marker: { homeworld: 'homeworld:choam', controller: 'r-seat' } };
}
void test('marker-only presence forbids the native and Richese alliance in either order', () => {
const { context, custody, marker } = fixture(), before = structuredClone(custody);
assert.equal(homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat'), null);
assert.match(homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat', marker)!, /cannot form/);
assert.match(homeworldAllianceBlock(context, custody, 'c-seat', 'r-seat', marker)!, /cannot form/);
assert.equal(homeworldAllianceBlock(context, custody, 'r-seat', 'e-seat', marker), null);
assert.equal(homeworldAllianceBlock(context, custody, 'c-seat', 'e-seat', marker), null);
assert.deepEqual(custody, before);
});
void test('physical visitors retain their original rule and marker adds no physical counter', () => {
const { context, custody, marker } = fixture();
custody.visitors['homeworld:ecaz'] = { 'c-seat': { normal: 1, elite: 0 } };
const before = structuredClone(custody);
assert.match(homeworldAllianceBlock(context, custody, 'c-seat', 'e-seat')!, /cannot form/);
assert.match(homeworldAllianceBlock(context, custody, 'c-seat', 'e-seat', marker)!, /cannot form/);
assert.deepEqual(custody, before);
});
void test('only a seated Richese controller on an active foreign World is accepted', () => {
const { context, custody, marker } = fixture();
assert.throws(() => homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat',
{ ...marker, controller: 'e-seat' }), /Richese controller/);
assert.throws(() => homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat',
{ ...marker, homeworld: 'homeworld:richese' }), /foreign world/);
assert.throws(() => homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat',
{ ...marker, homeworld: 'homeworld:atreides' }), /active foreign world/);
});
void test('server Homeworld integrity rejects an alliance across an actual marker-only arrival', () => {
const fixture = createRicheseHomeworldShipmentFixture({ companions: 1, advanced: false });
let game = fixture.game;
const view = viewGame(game, fixture.owner), option = view.homeworldShipment;
assert.ok(option && view.richeseNoField?.private);
game = applyAction(game, fixture.owner, { type: 'homeworldShip', event: option.event,
destination: 'homeworld:emperor', sources: {}, noField: fixture.token,
noFieldEvent: view.richeseNoField.event, allyPayment: 0 });
game = passRicheseHomeworldShipment(game);
assert.deepEqual(game.players.find(player => player.id === fixture.owner)!.noField!.deployed!.location,
{ homeworld: 'homeworld:emperor' });
assert.equal(game.homeworlds!.custody!.visitors['homeworld:emperor']?.[fixture.owner], undefined);
assert.doesNotThrow(() => homeworldGameIntegrity(game));
const illegal = structuredClone(game);
const richese = illegal.players.find(player => player.id === fixture.owner)!;
const emperor = illegal.players.find(player => player.id === fixture.holder)!;
richese.ally = emperor.id; emperor.ally = richese.id;
assert.throws(() => homeworldGameIntegrity(illegal), /alliance cannot form/);
});
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer } from '../game/engine';
import { presenceAt, presenceByLocation } from '../game/force-presence';
import { forceInventory } from '../game/force-inventory';
import { quoteBattleBoard } from '../game/board-resolution-quote';
import { quoteVictory } from '../game/victory-quote';
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField } from '../game/richese-no-field';
function game() {
const g = createGame('WORLDMARKER', newPlayer('r', 'Richese', 'richese'), true);
g.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
Object.assign(g, { status: 'playing', phase: 8, turn: 2, storm: 18,
order: ['r', 'a', 'b'], ready: [], phaseOpening: null, active: null });
for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, hand: [], spice: 5 });
return g;
}
function marker(tokenId: string, location: { homeworld: string } | { territory: string; sector: number }) {
return deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), {
tokenId, controller: 'r', location,
});
}
void test('a Homeworld marker does not fabricate Arrakis presence, a board battle or a stronghold win', () => {
const g = game(), richese = g.players[0], atreides = g.players[1];
richese.forces = { 'carthag:11': 1, 'sietch_tabr:14': 1 };
richese.noField = marker('three', { homeworld: 'homeworld:atreides' });
assert.equal(presenceAt(richese, 'arrakeen'), 0);
assert.equal(presenceAt(richese, 'homeworld:atreides'), 0);
assert.deepEqual(presenceByLocation(richese), richese.forces);
assert.deepEqual(quoteVictory(g).winner, []);
atreides.forces = { 'arrakeen:10': 1 };
assert.deepEqual(quoteBattleBoard(g).battles, []);
// The same single marker on Arrakis continues to participate normally.
richese.noField = marker('three', { territory: 'arrakeen', sector: 10 });
assert.equal(presenceAt(richese, 'arrakeen'), 1);
assert.equal(presenceByLocation(richese)['arrakeen:10'], 1);
assert.equal(quoteBattleBoard(g).battles.length, 1);
atreides.forces = {};
assert.deepEqual(quoteVictory(g).winner, ['r']);
});
void test('the inspector names a Homeworld marker without adding a counter or exposing its value', () => {
const g = game(), richese = g.players[0];
const inventories = ['zero', 'three', 'five'].map(tokenId => {
const state = marker(tokenId, { homeworld: 'homeworld:atreides' });
const player = { ...richese, noField: projectRicheseNoField(state, false) };
const inventory = forceInventory(player);
assert.ok(inventory);
assert.deepEqual(inventory.marker, { name: 'Caladan' });
assert.equal(inventory.total, 20);
assert.doesNotMatch(JSON.stringify(inventory), /zero|three|five|tokenId|lastShipped/);
return inventory;
});
assert.deepEqual(inventories[0], inventories[1]);
assert.deepEqual(inventories[1], inventories[2]);
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { quoteHomeworldBattles, homeworldCombatArmy, homeworldCombatLocation,
type HomeworldCombatContext } from '../game/homeworld-combat';
import { createHomeworldCustody, type HomeworldCustody } from '../game/homeworld-custody';
import { homeworldNoFieldPresence, quoteHomeworldBattlesWithNoField,
type PublicHomeworldNoField } from '../game/richese-homeworld-marker-combat';
function fixture(): { context: HomeworldCombatContext; custody: HomeworldCustody; marker: PublicHomeworldNoField } {
const context: HomeworldCombatContext = {
advanced: true,
order: ['r', 'e', 'a'],
players: [
{ id: 'e', faction: 'emperor', reserves: 6, eliteReserves: 2, ally: null },
{ id: 'a', faction: 'atreides', reserves: 5, eliteReserves: 0, ally: null },
{ id: 'r', faction: 'richese', reserves: 7, eliteReserves: 0, ally: null },
],
};
return { context, custody: createHomeworldCustody(context),
marker: { controller: 'r', location: { homeworld: 'homeworld:emperor' } } };
}
void test('a public marker alone adds Richese as a Homeworld combatant without creating a counter', () => {
const { context, custody, marker } = fixture();
const beforeContext = structuredClone(context), beforeCustody = structuredClone(custody);
assert.deepEqual(quoteHomeworldBattles(context, custody), []);
assert.deepEqual(homeworldNoFieldPresence(context, custody, marker), {
controller: 'r', homeworld: 'homeworld:emperor', effectiveForces: 1,
});
assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, marker), [
{ territory: 'homeworld:emperor', attacker: 'r', defender: 'e' },
]);
assert.equal(homeworldCombatLocation(context, custody, marker.location.homeworld).population, 4);
assert.deepEqual(homeworldCombatArmy(context, custody, marker.location.homeworld, 'r'),
{ player: 'r', normal: 0, elite: 0, native: false, nativeBattleStrength: 0 });
assert.deepEqual(context, beforeContext); assert.deepEqual(custody, beforeCustody);
});
void test('a marker and existing Richese garrison do not duplicate battles or physical forces', () => {
const { context, custody, marker } = fixture();
custody.visitors['homeworld:emperor'] = { r: { normal: 2, elite: 0 }, a: { normal: 1, elite: 0 } };
const before = structuredClone(custody), native = quoteHomeworldBattles(context, custody);
assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, marker), native);
assert.equal(native.filter(pair => pair.attacker === 'r' && pair.defender === 'e').length, 1);
assert.deepEqual(homeworldCombatArmy(context, custody, marker.location.homeworld, 'r'),
{ player: 'r', normal: 2, elite: 0, native: false, nativeBattleStrength: 0 });
assert.deepEqual(custody, before);
});
void test('zero native forces are not invented and a foreign army may oppose the marker', () => {
const { context, custody, marker } = fixture();
const emperor = context.players.find(player => player.id === 'e')!;
emperor.reserves = 0; emperor.eliteReserves = 0;
custody.salusa = { normal: 0, elite: 0 };
custody.visitors['homeworld:emperor'] = { a: { normal: 1, elite: 0 } };
assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, marker), [
{ territory: 'homeworld:emperor', attacker: 'r', defender: 'a' },
]);
});
void test('allies do not become opponents and public pairing is independent of the hidden value', () => {
const { context, custody, marker } = fixture();
context.players.find(player => player.id === 'r')!.ally = 'a';
context.players.find(player => player.id === 'a')!.ally = 'r';
custody.visitors['homeworld:emperor'] = { a: { normal: 1, elite: 0 } };
const expected = [
{ territory: 'homeworld:emperor', attacker: 'r', defender: 'e' },
{ territory: 'homeworld:emperor', attacker: 'e', defender: 'a' },
];
for (const value of [0, 3, 5]) {
// Extra private fields must never influence this public quote.
const specimen = { ...marker, tokenId: `hidden-${value}`, value };
assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, specimen), expected);
}
});
void test('a marker needs its seated Richese controller and a foreign active Homeworld', () => {
const { context, custody, marker } = fixture();
assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, null), quoteHomeworldBattles(context, custody));
assert.throws(() => homeworldNoFieldPresence(context, custody, { ...marker, controller: 'e' }), /Richese/);
assert.throws(() => homeworldNoFieldPresence(context, custody, { ...marker, controller: 'missing' }), /Richese/);
assert.throws(() => homeworldNoFieldPresence(context, custody,
{ ...marker, location: { homeworld: 'homeworld:richese' } }), /own Homeworld/);
assert.throws(() => homeworldNoFieldPresence(context, custody,
{ ...marker, location: { homeworld: 'homeworld:fremen' } }), /canonical Homeworld/);
});
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import type { Action, GameView } from '../game/engine';
import { createRicheseNoField } from '../game/richese-no-field';
import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
import { homeworldPopulations } from '../game/homeworld-population';
import * as combat from '../game/combat';
import * as options from '../game/homeworld-shipment-options';
import { HomeworldNoFieldShipment } from '../components/homeworld-shipment';
function fixture(): GameView {
const context = { advanced: true, players: [
{ id: 'r', faction: 'richese' as const, reserves: 12, eliteReserves: 0, ally: null as string | null },
{ id: 'a', faction: 'atreides' as const, reserves: 15, eliteReserves: 0, ally: null as string | null },
{ id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null as string | null },
] };
const custody: HomeworldCustody = { salusa: null, visitors: {} };
const populations = homeworldPopulations(context, custody);
return {
me: 'r', active: 'r', advanced: true, status: 'playing', phase: 5,
homeworldShipment: { event: 'homeworld-event', blocked: null },
aid: { available: 0, pledged: 0 },
players: context.players.map(p => ({ ...p, name: p.faction, spice: p.id === 'r' ? 4 : undefined,
shipped: false, elites: { reserves: 0 } })),
homeworlds: { worlds: homeworldForceGroups(context, custody).map(world => ({ ...world,
...populations.find(population => population.location === world.id)! })) },
richeseNoField: { owner: 'r', event: 'marker-event', private: createRicheseNoField(['zero', 'three', 'five']),
canShip: true, shipBlock: null },
} as unknown as GameView;
}
void test('private marker quote emits the native shipment action without withdrawing counters', () => {
const view = fixture(), before = structuredClone(view);
for (const tokenId of ['zero', 'three', 'five']) {
const quote = options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', tokenId);
assert.equal(quote.cost, 1); assert.equal(quote.ownPayment, 1);
assert.deepEqual(quote.action, { type: 'homeworldShip', event: 'homeworld-event', destination: 'homeworld:atreides',
sources: {}, noField: tokenId, noFieldEvent: 'marker-event', allyPayment: 0 });
}
assert.deepEqual(view, before);
});
void test('marker quote respects eligibility, payment, privacy and current action windows', () => {
const view = fixture(), info = view.richeseNoField!;
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'zero').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:unknown', 'zero').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'missing').action, null);
info.private!.lastShipped = 'zero';
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'zero').action, null);
info.private!.lastShipped = null;
view.players[0].spice = 0;
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three').action, null);
view.players[0].ally = 'g'; view.players[2].ally = 'r'; view.aid.available = 1;
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'three', 1).action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 1).action?.allyPayment, 1);
for (const contribution of [-1, 0.5, 2, NaN])
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', contribution).action, null);
for (const patch of [
{ phase: 6 }, { active: 'a' }, { status: 'finished' },
{ roomControl: { paused: true } }, { homeworldShipment: { event: 'homeworld-event', blocked: 'Wait.' } },
{ richeseNoField: { ...info, owner: 'a', private: null } },
]) assert.equal(options.richeseHomeworldMarkerChoice({ ...view, ...patch } as GameView,
'homeworld:atreides', 'three', 1).action, null);
});
void test('only the owner sees the provisional marker controls and private token values', () => {
const view = fixture();
const owner = renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: view, act() {}, busy: false }));
assert.match(owner, /Provisional Homeworld rule/);
assert.match(owner, /No-Field 0/); assert.match(owner, /No-Field 3/); assert.match(owner, /No-Field 5/);
assert.match(owner, /one spice total/);
const other = { ...view, me: 'a', richeseNoField: { ...view.richeseNoField!, private: null } } as GameView;
assert.equal(renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: other, act() {}, busy: false })), '');
});
void test('all existing bot profiles can offer a legal marker when physical reserves are empty', () => {
const view = fixture();
view.players[0].reserves = 0;
const own = view.homeworlds!.worlds!.find(world => world.id === 'homeworld:richese')!;
own.forces.r.normal = 0;
for (const level of [0, 1, 2, 3]) {
const action = options.homeworldShipmentActions(view, level).find(candidate => candidate.noField !== undefined);
assert.ok(action, `profile ${level} can offer its physical marker`);
assert.deepEqual(action, options.richeseHomeworldMarkerChoice(view, String(action.destination),
String(action.noField), Number(action.allyPayment)).action);
}
});
void test('optional modules block the marker quote and all bot marker candidates', () => {
const view = fixture();
for (const patch of [
{ nexusCards: { waiting: [] } }, { leaderSkills: {} }, { discoveryEnabled: true },
{ discoveries: {} }, { techTokens: {} }, { strongholdCards: {} },
]) {
const withModule = { ...view, ...patch } as GameView;
const quote = options.richeseHomeworldMarkerChoice(withModule, 'homeworld:atreides', 'three');
assert.equal(quote.action, null);
assert.match(quote.blocked ?? '', /without optional modules/);
for (const level of [0, 1, 2, 3])
assert.equal(options.homeworldShipmentActions(withModule, level)
.some(candidate => candidate.noField !== undefined), false);
}
});
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
void test('the actual marker controls submit the selected private token and destination', () => {
const view = fixture(), sent: Action[] = [], states: unknown[] = [];
let cursor = 0;
const exports: { HomeworldNoFieldShipment?: typeof HomeworldNoFieldShipment } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/homeworld-shipment.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports,
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return { useState(initial: unknown) {
const index = cursor++; if (!(index in states)) states[index] = initial;
return [states[index], (next: unknown) => { states[index] = next; }];
} };
if (name === '@/game/combat') return combat;
if (name === '@/game/homeworld-shipment-options') return options;
if (name === './ui/button') return { Button: 'button' };
if (name === './ui/input') return { Input: 'input' };
throw new Error(`Unexpected import ${name}`);
},
});
assert.ok(exports.HomeworldNoFieldShipment);
const render = () => { cursor = 0; return elements(exports.HomeworldNoFieldShipment!({ game: view, busy: false,
act: action => sent.push(action) })); };
const select = (label: string, value: string) => {
const node = render().find(element => element.type === 'select' && element.props['aria-label'] === label);
assert.ok(node); (node.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
};
select('No-Field destination Homeworld', 'homeworld:guild');
select('Private No-Field token', 'five');
const button = render().find(element => element.type === 'button');
assert.ok(button); assert.equal(button.props.disabled, false);
(button.props.onClick as () => void)();
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type: 'homeworldShip', event: 'homeworld-event',
destination: 'homeworld:guild', sources: {}, noField: 'five', noFieldEvent: 'marker-event', allyPayment: 0 });
});
@@ -0,0 +1,88 @@
// @dune-suite integration
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
import type { Action, GameView } from '../game/engine';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
async function request(path: string, cookie?: string, body?: unknown) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000),
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...(cookie ? { cookie } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0],
data: await response.json() as GameView & { error?: string } };
}
/** All state belongs to the new QA room. The existing bot adapter proposes
* legal actions from each participant's own authenticated view; no server
* state, physical tokens, phases, hands or balances are staged. */
void test('authenticated Richese ships a concealed marker to a Homeworld without revealing its identity', async () => {
const host = await request('/api/rooms', undefined, {
name: 'Homeworld marker owner', faction: 'richese', advanced: false, expansions: ['choam'],
});
assert.equal(host.status, 201, JSON.stringify(host.data)); assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const peer = await request(path, undefined, { type: 'join', name: 'Homeworld marker opponent', faction: 'atreides' });
assert.equal(peer.status, 200, JSON.stringify(peer.data)); assert.ok(peer.cookie);
const seats = [{ cookie: host.cookie, id: host.data.me }, { cookie: peer.cookie, id: peer.data.me }];
let view = peer.data;
const act = async (cookie: string, action: Action) => {
const next = await request(path, cookie, { version: view.version, action });
assert.equal(next.status, 200, `Rejected ${action.type}: ${JSON.stringify(next.data)}`);
view = next.data; return next.data;
};
await act(host.cookie, { type: 'homeworlds', enabled: true });
for (const seat of seats) await act(seat.cookie, { type: 'ready' });
await act(host.cookie, { type: 'start' });
async function read(cookie: string): Promise<GameView> {
const result = await request(path, cookie);
assert.equal(result.status, 200, JSON.stringify(result.data));
view = result.data; return result.data;
}
async function advance(until: (owner: GameView) => boolean) {
for (let count = 0; count < 300; count++) {
const owner = await read(host.cookie!);
if (until(owner)) return owner;
assert.notEqual(owner.status, 'finished', 'The QA game ended before its marker shipment.');
let proposed: { cookie: string; action: Action } | null = null;
for (const seat of seats) {
const current = await read(seat.cookie!);
const local = structuredClone(current);
const self = local.players.find(player => player.id === seat.id); assert.ok(self);
self.bot = 'Easy'; // Local action adapter only; the server seat remains human.
const actions = botActions(local);
if (actions.length) { proposed = { cookie: seat.cookie!, action: actions[0] }; break; }
}
assert.ok(proposed, `No native continuation at ${owner.status}/${owner.phase}/${owner.decision?.kind ?? 'no decision'}.`);
// During response windows pass rather than spending a physical Karama.
if (view.response && !view.response.passed.includes(view.me)) proposed.action = { type: 'passResponse' };
await act(proposed.cookie, proposed.action);
}
throw Error('The dedicated HTTP room did not reach its finite native marker boundary.');
}
const before = await advance(owner => owner.status === 'playing' && owner.phase === 5 && owner.active === owner.me &&
!!owner.homeworldShipment && !owner.homeworldShipment.blocked && !!owner.richeseNoField?.canShip);
const info = before.richeseNoField!; assert.ok(info.private);
const token = info.private.tokens.find(candidate => candidate.id !== info.private!.lastShipped); assert.ok(token);
const tokens = info.private.tokens.map(candidate => candidate.id);
const payer = before.players.find(candidate => candidate.id === before.me)!;
const originalSpice = payer.spice, originalReserves = payer.reserves;
const quote = richeseHomeworldMarkerChoice(before, 'homeworld:atreides', token.id);
assert.ok(quote.action, quote.blocked ?? 'The original private shipment should be available.');
await act(host.cookie, quote.action);
const settled = await advance(owner => owner.status === 'playing' && owner.phase === 5 &&
!!owner.richeseNoField?.private?.deployed && owner.players.find(player => player.id === owner.me)?.shipped === true);
const after = settled.players.find(candidate => candidate.id === settled.me)!;
assert.equal(after.spice, originalSpice! - 1);
assert.equal(after.reserves, originalReserves);
assert.deepEqual(settled.richeseNoField!.private!.deployed!.location, { homeworld: 'homeworld:atreides' });
const peerView = await read(peer.cookie);
assert.equal(peerView.richeseNoField!.private, null);
assert.deepEqual(peerView.richeseNoField!.public.deployed!.location, { homeworld: 'homeworld:atreides' });
for (const id of tokens) assert.equal(JSON.stringify(peerView).includes(id), false, 'A foreign seat cannot see token IDs.');
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createRicheseNoField, deployRicheseNoField, isBoardNoFieldLocation,
isHomeworldNoFieldLocation, moveRicheseNoField, projectRicheseNoField,
recallRicheseNoField, revealRicheseNoField, validateRicheseNoField,
type NoFieldLocation, type RicheseNoField,
} from '../game/richese-no-field';
const ids = ['zero', 'three', 'five'];
const home = { homeworld: 'homeworld:atreides' } as const;
const planet = { territory: 'polar_sink', sector: 0 } as const;
function placed(location: NoFieldLocation = home, tokenId = 'three') {
return deployRicheseNoField(createRicheseNoField(ids), { tokenId, controller: 'richese', location });
}
void test('Homeworld marker preserves single physical token and hides its face in public projection', () => {
const state = placed();
assert.equal(state.lastShipped, 'three');
assert.deepEqual(projectRicheseNoField(state, false), {
deployed: { controller: 'richese', location: home, effectiveForces: 1 }, lastUsed: null,
});
assert.deepEqual(projectRicheseNoField(state, true).private, state);
assert.throws(() => deployRicheseNoField(state, { tokenId: 'five', controller: 'richese', location: planet }));
assert.equal(isHomeworldNoFieldLocation(state.deployed!.location), true);
assert.equal(isBoardNoFieldLocation(state.deployed!.location), false);
});
void test('Homeworld reveal returns only existing reserves and keeps the prior shipment history', () => {
for (const [tokenId, reserves, forces] of [['zero', 5, 0], ['three', 1, 1], ['five', 3, 3]] as const) {
const before = placed(home, tokenId);
const snapshot = structuredClone(before);
const result = revealRicheseNoField(before, { tokenId, reserves, cause: 'battle' });
assert.equal(result.forces, forces);
assert.deepEqual(result.location, home);
assert.equal(result.state.deployed, null);
assert.equal(result.state.lastShipped, tokenId);
assert.deepEqual(before, snapshot);
assert.throws(() => deployRicheseNoField(result.state, { tokenId, controller: 'richese', location: planet }));
}
});
void test('board and Homeworld location custody survives JSON and a face-down recall', () => {
const board = placed(planet);
assert.equal(isBoardNoFieldLocation(board.deployed!.location), true);
assert.equal(isHomeworldNoFieldLocation(board.deployed!.location), false);
assert.deepEqual(JSON.parse(JSON.stringify(board)), board);
validateRicheseNoField(JSON.parse(JSON.stringify(board)) as RicheseNoField);
const moved = moveRicheseNoField(board, 'three', home);
assert.deepEqual(board.deployed!.location, planet);
assert.deepEqual(moved.deployed!.location, home);
const loaded = JSON.parse(JSON.stringify(moved)) as RicheseNoField;
validateRicheseNoField(loaded);
const returned = recallRicheseNoField(loaded, 'three');
assert.equal(returned.deployed, null);
assert.equal(returned.hiddenReturn, true);
assert.equal(projectRicheseNoField(returned, false).lastUsed, null);
assert.throws(() => deployRicheseNoField(returned, { tokenId: 'three', controller: 'richese', location: home }));
});
void test('Homeworld locations require a canonical identity and cannot mix with territory shape', () => {
const fresh = createRicheseNoField(ids);
for (const location of [
{ homeworld: 'homeworld:emperor:salusa' }, { homeworld: 'homeworld:choam' },
]) assert.doesNotThrow(() => deployRicheseNoField(fresh, { tokenId: 'zero', controller: 'richese', location }));
for (const location of [
{ homeworld: 'homeworld:unknown' }, { homeworld: '' },
{ homeworld: 'homeworld:atreides', territory: 'polar_sink', sector: 0 },
{ territory: 'homeworld:atreides', sector: 0 },
]) assert.throws(() => deployRicheseNoField(fresh, {
tokenId: 'zero', controller: 'richese', location: location as NoFieldLocation,
}));
});
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createHomeworldCustody, type HomeworldCustodyContext } from '../game/homeworld-custody';
import { createHomeworldOccupationHistory, observeHomeworldOccupation,
validateHomeworldOccupationHistory, readHomeworldOccupationSnapshot,
currentHomeworldOccupationSnapshot, homeworldOccupationSnapshotPresence,
homeworldOccupationMarker } from '../game/homeworld-occupation-history';
import { validateHomeworldEcazDukeGrants, type HomeworldEcazDukeContext } from '../game/homeworld-ecaz-duke';
function fixture() {
const context: HomeworldCustodyContext = { advanced: false, players: [
{ id: 'richese-seat-17', faction: 'richese', reserves: 10, eliteReserves: 0 },
{ id: 'choam-seat-42', faction: 'choam', reserves: 0, eliteReserves: 0 },
{ id: 'ecaz-seat-99', faction: 'ecaz', reserves: 0, eliteReserves: 0 },
] };
const custody = createHomeworldCustody(context);
const history = createHomeworldOccupationHistory(context, custody, 1, 'setup');
const marker = { homeworld: 'homeworld:choam', controller: 'richese-seat-17' };
return { context, custody, history, marker };
}
void test('marker-free history preserves the canonical original physical snapshot and source signature', () => {
const { context, custody, history } = fixture();
const groups = [
['homeworld:choam', [['choam-seat-42', 0, 0]]],
['homeworld:ecaz', [['ecaz-seat-99', 0, 0]]],
['homeworld:richese', [['richese-seat-17', 10, 0]]],
];
const physical = JSON.stringify(groups);
assert.equal(history.snapshots[0], physical);
assert.equal(history.sources[0].signature,
JSON.stringify(['homeworldOccupationSource', 'setup', 1, 'setup', physical]));
assert.deepEqual(readHomeworldOccupationSnapshot(physical, context), { groups, marker: null });
assert.deepEqual(currentHomeworldOccupationSnapshot(context, custody), { groups, marker: null });
validateHomeworldOccupationHistory(JSON.parse(JSON.stringify(history)), context, 1);
});
void test('marker-only arrival and departure create distinct observed facts without physical counters', () => {
const { context, custody, history, marker } = fixture();
const beforeCustody = structuredClone(custody), beforeContext = structuredClone(context);
const arrival = observeHomeworldOccupation(history, context, custody, 1, 'change', 'arrival', marker);
assert.notEqual(arrival.snapshots[arrival.sources.at(-1)!.snapshot], history.snapshots[0]);
const snapshot = readHomeworldOccupationSnapshot(arrival.snapshots[arrival.sources.at(-1)!.snapshot], context);
assert.deepEqual(snapshot.marker, marker);
assert.deepEqual(homeworldOccupationSnapshotPresence(snapshot, marker.homeworld), [marker.controller]);
assert.deepEqual(arrival.qualifications, [{ event: 'arrival', world: marker.homeworld,
player: marker.controller, faction: 'richese', turn: 1, cause: 'sole' }]);
assert.deepEqual(observeHomeworldOccupation(arrival, context, custody, 1, 'change', 'arrival', marker), arrival);
assert.throws(() => observeHomeworldOccupation(arrival, context, custody, 1, 'change', 'arrival'), /different physical facts/);
const departure = observeHomeworldOccupation(arrival, context, custody, 1, 'change', 'departure');
assert.equal(departure.sources.at(-1)!.event, 'departure');
assert.equal(departure.sources.at(-1)!.snapshot, history.sources[0].snapshot);
assert.deepEqual(departure.qualifications, arrival.qualifications);
assert.deepEqual(custody, beforeCustody); assert.deepEqual(context, beforeContext);
validateHomeworldOccupationHistory(JSON.parse(JSON.stringify(departure)), context, 1);
});
void test('marker and physical garrison of one controller qualify once; invalid World identities reject', () => {
const { context, custody, history, marker } = fixture();
custody.visitors[marker.homeworld] = { [marker.controller]: { normal: 1, elite: 0 } };
const frame = currentHomeworldOccupationSnapshot(context, custody, marker);
assert.deepEqual(homeworldOccupationSnapshotPresence(frame, marker.homeworld), [marker.controller]);
const observed = observeHomeworldOccupation(history, context, custody, 1, 'change', 'mixed', marker);
assert.equal(observed.qualifications.length, 1);
assert.throws(() => currentHomeworldOccupationSnapshot(context, custody,
{ homeworld: 'homeworld:richese', controller: marker.controller }), /foreign world/);
assert.throws(() => currentHomeworldOccupationSnapshot(context, custody,
{ homeworld: marker.homeworld, controller: 'choam-seat-42' }), /Richese controller/);
assert.throws(() => currentHomeworldOccupationSnapshot(context, custody,
{ homeworld: 'homeworld:fremen', controller: marker.controller }), /active foreign world/);
});
void test('marker extraction is public only and ignores an Arrakis token', () => {
const { marker } = fixture();
const seats = [{ id: marker.controller, faction: 'richese' as const,
noField: { deployed: { controller: marker.controller, location: { homeworld: marker.homeworld } } } }];
assert.deepEqual(homeworldOccupationMarker(seats), marker);
assert.equal(homeworldOccupationMarker([{ ...seats[0], noField: { deployed: {
controller: marker.controller, location: { territory: 'arrakeen', sector: 10 },
} } }]), null);
});
for (const source of ['physical', 'marker'] as const) void test(`Ecaz Duke grant validates its canonical faction world with opaque seat IDs and ${source} presence`, () => {
const { context, custody, history } = fixture();
const marker = { homeworld: 'homeworld:ecaz', controller: 'richese-seat-17' };
if (source === 'physical') custody.visitors[marker.homeworld] = { [marker.controller]: { normal: 1, elite: 0 } };
const occupied = observeHomeworldOccupation(history, context, custody, 1, 'change', 'ecaz-occupation',
source === 'marker' ? marker : null);
const game = {
status: 'playing', advanced: false, turn: 1, homeworldOccupationPreview: true,
players: context.players.map(player => ({ id: player.id, faction: player.faction,
reserves: player.reserves, ally: null })),
homeworlds: { custody, historyVersion: 1 }, homeworldOccupationHistory: occupied,
homeworldEcazDukeGrants: [{ turn: 1, qualification: 'ecaz-occupation',
occupier: marker.controller, recipient: marker.controller }],
} as unknown as HomeworldEcazDukeContext;
assert.doesNotThrow(() => validateHomeworldEcazDukeGrants(game));
});
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer,
viewGame, type Action, type Game } from '../game/engine';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
const richese = 'richese', opponent = 'atreides', world = 'homeworld:atreides';
const player = (game: Game, id: string) => {
const seat = game.players.find(candidate => candidate.id === id); assert.ok(seat); return seat;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
/** Follow only the real native decisions and response queues. The Guild's
* actual Advanced stop opportunity is explicitly allowed and recorded. */
function advance(game: Game, until: (state: Game) => boolean, trace: string[] = []): Game {
let state = game;
for (let count = 0; count < 2000; count++) {
if (until(state)) return state;
assert.notEqual(state.status, 'finished');
if (state.response) trace.push(`response:${state.response.kind}`);
if (state.decision?.kind === 'homeworldShipmentGuild') {
const d = state.decision; trace.push(`decision:${d.kind}`);
state = applyAction(state, d.player, { type: 'decision', event: d.event, allow: true });
continue;
}
const step = nextIndependentCardModulesStep(state);
assert.ok(step, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}`);
state = applyAction(state, step.actor, step.action);
}
throw Error('The native Homeworld marker programme did not reach its boundary.');
}
function setup(advanced = true) {
let game = createGame('RICHESEHOMEWORLDMARKER', newPlayer(richese, richese, 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(opponent, opponent, 'atreides'));
joinGame(game, newPlayer('guild', 'guild', 'guild'));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
// Use the original public start; Advanced explicitly acknowledges preview.
// No phase, resource, army or marker is assigned by the fixture.
game = applyAction(game, game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
// Select one position in the original still-undealt deck, so Guild receives
// a real Karama at its starting-card deal. This makes both the cancel window
// and the Guild stop observable rather than automatically passed.
const karama = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karama >= 0 && game.deck.length >= 3);
[game.deck[2], game.deck[karama]] = [game.deck[karama], game.deck[2]];
game = advance(game, state => state.status === 'playing');
assert.ok(player(game, 'guild').hand.some(card => card.effect === 'karama'));
return advance(game, state => state.phase === 5 && state.active === richese && clean(state) &&
!!viewGame(state, richese).homeworldShipment && !!viewGame(state, richese).richeseNoField?.canShip);
}
function inventory(game: Game) {
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
return Object.fromEntries(game.players.map(seat => [seat.id, seat.reserves + seat.tanks +
Object.values(seat.forces).reduce((sum, value) => sum + value, 0) + homes
.filter(home => home.native !== seat.id)
.reduce((sum, home) => sum + (home.forces[seat.id]?.normal ?? 0) + (home.forces[seat.id]?.elite ?? 0), 0)]));
}
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} Richese ships its ${value} marker to an actual Homeworld battle, reveals and finishes`, () => {
let game = setup(advanced);
const initial = structuredClone(game), originalInventory = inventory(game);
const own = viewGame(game, richese), marker = own.richeseNoField!.private!.tokens.find(token => token.value === value);
assert.ok(marker); assert.equal(own.homeworldShipment!.blocked, null);
const declaration: Action = { type: 'homeworldShip', event: own.homeworldShipment!.event,
destination: world, sources: {}, noField: marker.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 };
game = applyAction(game, richese, declaration);
assert.equal(game.response?.kind, 'richeseNoField');
assert.equal(player(game, richese).spice, player(initial, richese).spice,
'The pending Karama response cannot charge the shipper.');
assert.deepEqual(player(game, richese).noField, player(initial, richese).noField);
const trace: string[] = [];
game = advance(game, state => state.active === richese && state.phase === 5 && clean(state) &&
player(state, richese).shipped, trace);
assert.ok(trace.includes('response:richeseNoField'), 'The original Karama opportunity completed.');
assert.equal(trace.includes('decision:homeworldShipmentGuild'), advanced,
advanced ? 'The original Advanced Guild stop was allowed.' : 'Basic does not grant the Advanced Guild stop.');
assert.equal(player(game, richese).spice, player(initial, richese).spice - 1);
assert.equal(player(game, richese).reserves, player(initial, richese).reserves);
assert.deepEqual(inventory(game), originalInventory);
assert.deepEqual(player(game, richese).noField!.deployed!.location, { homeworld: world });
assert.equal(player(game, richese).noField!.lastShipped, marker.id);
const observer = viewGame(game, opponent).richeseNoField!;
assert.equal(observer.private, null);
assert.deepEqual(observer.public.deployed!.location, { homeworld: world });
assert.equal(JSON.stringify(observer.public).includes(marker.id), false, 'A face-down marker does not publish its identity.');
game = applyAction(game, richese, { type: 'endMovement' });
game = advance(game, state => state.phase === 6 && clean(state) && !state.battle && !!state.active &&
viewGame(state, state.active).battleChoices.some(choice => choice.territory === world));
const chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(candidate => candidate.territory === world)!;
const target = choice.attacker === chooser ? choice.defender : choice.attacker;
game = applyAction(game, chooser, { type: 'chooseBattle', territory: world, target });
game = advance(game, state => !!state.battle && !state.battle.revealed && clean(state) &&
(!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation);
const event = game.battle!.event;
assert.ok(game.battle!.noFieldPlayers?.includes(richese));
for (const actor of [richese, opponent]) {
game = advance(game, state => !!state.battle && !state.battle.revealed && clean(state) &&
(!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation);
const leader = player(game, actor).leaders.find(candidate => !candidate.dead &&
(!candidate.usedAt || candidate.usedAt === world));
assert.ok(leader);
game = applyAction(game, actor, { type: 'battlePlan', dial: 0, support: 0, allyPayment: 0,
leader: leader.id, weapon: null, defense: null });
if (actor === richese) assert.ok(player(game, richese).noField!.deployed, 'One seal does not reveal the marker.');
}
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, richese).noField!.deployed, null);
assert.equal(player(game, richese).reserves, player(initial, richese).reserves - value);
assert.equal(game.homeworlds!.custody!.visitors[world]?.[richese]?.normal ?? 0, value);
assert.deepEqual(inventory(game), originalInventory);
game = advance(game, state => !state.battle && state.lastBattleContext?.event === event && clean(state));
assert.deepEqual(inventory(game), originalInventory);
});
}
void test('Karama cancels the proposed Homeworld marker without charging or spending the shipment', () => {
let game = setup();
const before = structuredClone(game), own = viewGame(game, richese);
const token = own.richeseNoField!.private!.tokens.find(candidate => candidate.value === 3)!;
const karama = player(game, 'guild').hand.find(card => card.effect === 'karama')!;
game = applyAction(game, richese, { type: 'homeworldShip', event: own.homeworldShipment!.event,
destination: world, sources: {}, noField: token.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 });
assert.equal(game.response?.kind, 'richeseNoField');
game = applyAction(game, 'guild', { type: 'card', mode: 'cancel', card: karama.id });
game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) && !state.pendingHomeworldShipment);
assert.equal(player(game, richese).spice, player(before, richese).spice);
assert.equal(player(game, richese).reserves, player(before, richese).reserves);
assert.equal(player(game, richese).shipped, false);
assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
assert.deepEqual(inventory(game), inventory(before));
assert.equal(player(game, richese).noFieldBlockedTurn, game.turn);
assert.equal(viewGame(game, richese).richeseNoField!.canShip, false);
const option = viewGame(game, richese).homeworldShipment!;
game = applyAction(game, richese, { type: 'homeworldShip', event: option.event,
destination: world, sources: { 'homeworld:richese': { normal: 1, elite: 0 } }, allyPayment: 0 });
game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) &&
player(state, richese).shipped && !state.pendingHomeworldShipment);
assert.equal(game.homeworlds!.custody!.visitors[world][richese].normal, 1,
'The original physical shipment is still available after preventing the marker.');
assert.equal(player(game, richese).spice, player(before, richese).spice - 1);
assert.deepEqual(inventory(game), inventory(before));
});
void test('Advanced Guild stops the Homeworld marker before payment or arrival while using the shipment', () => {
let game = setup();
const before = structuredClone(game), own = viewGame(game, richese);
const token = own.richeseNoField!.private!.tokens.find(candidate => candidate.value === 5)!;
const karama = player(game, 'guild').hand.find(card => card.effect === 'karama')!;
game = applyAction(game, richese, { type: 'homeworldShip', event: own.homeworldShipment!.event,
destination: world, sources: {}, noField: token.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 });
game = advance(game, state => state.decision?.kind === 'homeworldShipmentGuild');
assert.equal(player(game, richese).spice, player(before, richese).spice);
assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
game = applyAction(game, 'guild', { type: 'card', mode: 'special', card: karama.id });
game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) && !state.pendingHomeworldShipment);
assert.equal(player(game, richese).spice, player(before, richese).spice);
assert.equal(player(game, richese).reserves, player(before, richese).reserves);
assert.equal(player(game, richese).shipped, true);
assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
assert.deepEqual(inventory(game), inventory(before));
});
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { HomeworldCustodyError, type HomeworldCustody } from '../game/homeworld-custody';
import type { HomeworldShipmentContext } from '../game/homeworld-shipment';
import { createRicheseNoField, type RicheseNoField } from '../game/richese-no-field';
import { quoteRicheseHomeworldMarkerShipment as quote,
type RicheseHomeworldMarkerShipmentIntent } from '../game/richese-homeworld-marker-shipment';
function fixture(): { context: HomeworldShipmentContext; custody: HomeworldCustody; noField: RicheseNoField;
intent: RicheseHomeworldMarkerShipmentIntent } {
return {
context: { advanced: true, players: [
{ id: 'r', faction: 'richese', reserves: 12, eliteReserves: 0, ally: null },
{ id: 'a', faction: 'atreides', reserves: 17, eliteReserves: 0, ally: null },
{ id: 'g', faction: 'guild', reserves: 20, eliteReserves: 0, ally: null },
] },
custody: { salusa: null, visitors: { 'homeworld:atreides': { r: { normal: 2, elite: 0 } } } },
noField: createRicheseNoField(['zero', 'three', 'five']),
intent: { player: 'r', destination: 'homeworld:atreides', tokenId: 'zero' },
};
}
void test('each concealed token ships as one marker at one spice without withdrawing a physical reserve', () => {
for (const advanced of [false, true]) for (const tokenId of ['zero', 'three', 'five']) {
const f = fixture(); f.context.advanced = advanced; f.intent.tokenId = tokenId;
const before = structuredClone(f);
const result = quote(f.context, f.custody, f.intent, f.noField);
assert.deepEqual({ player: result.player, tokenId: result.tokenId, source: result.source,
destination: result.destination, amount: result.amount, elite: result.elite, cost: result.cost },
{ player: 'r', tokenId, source: 'homeworld:richese', destination: 'homeworld:atreides', amount: 1, elite: 0, cost: 1 });
assert.deepEqual(result.noField.deployed, { tokenId, controller: 'r', location: { homeworld: 'homeworld:atreides' } });
assert.equal(result.noField.lastShipped, tokenId);
assert.deepEqual(f, before);
result.noField.tokens[0].id = 'detached';
assert.deepEqual(f, before);
}
});
void test('rates and empty physical reserves do not alter the one-marker invoice', () => {
const f = fixture(); f.context.players[0].reserves = 0;
f.context.purchasedGuildRate = true; f.context.nativeGuildRateCanceled = true;
const result = quote(f.context, f.custody, f.intent, f.noField);
assert.equal(result.amount, 1); assert.equal(result.cost, 1);
assert.equal(f.context.players[0].reserves, 0);
assert.equal(f.custody.visitors['homeworld:atreides'].r.normal, 2);
});
void test('own, allied, inactive and forged destinations or actors reject without mutation', () => {
for (const change of [
(f: ReturnType<typeof fixture>) => { f.intent.player = 'g'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:richese'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:unknown'; },
(f: ReturnType<typeof fixture>) => { f.context.players[0].ally = 'a'; f.context.players[1].ally = 'r'; },
(f: ReturnType<typeof fixture>) => { (f.intent as unknown as Record<string, unknown>).extra = 1; },
(f: ReturnType<typeof fixture>) => { f.context.players[0].ally = 'missing'; },
]) {
const f = fixture(); change(f); const before = structuredClone(f);
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
assert.deepEqual(f, before);
}
});
void test('invalid, deployed and consecutively used physical markers cannot be shipped', () => {
for (const change of [
(f: ReturnType<typeof fixture>) => { f.intent.tokenId = 'missing'; },
(f: ReturnType<typeof fixture>) => { f.noField.lastShipped = 'zero'; },
(f: ReturnType<typeof fixture>) => {
f.noField = quote(f.context, f.custody, f.intent, f.noField).noField;
f.intent.tokenId = 'three';
},
(f: ReturnType<typeof fixture>) => { f.noField.tokens[1].value = 5; },
]) {
const f = fixture(); change(f); const before = structuredClone(f);
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
assert.deepEqual(f, before);
}
});
void test('malformed physical Homeworld custody is rejected before marker deployment', () => {
const f = fixture(); f.custody.visitors['homeworld:unknown'] = { r: { normal: 1, elite: 0 } };
const before = structuredClone(f);
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
assert.deepEqual(f, before);
});
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import { NoFieldBoardMarkers } from '../components/richese-no-field';
import { planetologistNoFieldMarker } from '../components/planetologist-movement-action';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image'
: specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { HomeworldTable } = await import('../components/homeworld-table');
aliases.deregister();
function view(location: { homeworld: string } | { territory: string; sector: number }): GameView {
const publicMarker = { controller: 'r', location, effectiveForces: 1 as const };
const privateMarker = { tokenId: 'private-token', controller: 'r', location };
return {
me: 'r',
players: [
{ id: 'r', name: 'Richese player', faction: 'richese', noField: { deployed: publicMarker, lastUsed: null } },
{ id: 'e', name: 'Emperor player', faction: 'emperor' },
],
richeseNoField: {
owner: 'r', event: 'marker-event',
public: { deployed: publicMarker, lastUsed: null },
private: { deployed: privateMarker, lastShipped: 'private-token',
tokens: [{ id: 'private-token', value: 5 }, { id: 'other', value: 3 }, { id: 'zero', value: 0 }] },
},
homeworlds: { worlds: [{ id: 'homeworld:emperor', card: 'kaitain', native: 'e',
side: 'high', population: 6, forces: { e: { normal: 6, elite: 0 } } }] },
} as unknown as GameView;
}
void test('a Homeworld marker appears separately from physical forces without its hidden value', () => {
const game = view({ homeworld: 'homeworld:emperor' });
const html = renderToStaticMarkup(createElement(HomeworldTable, { game }));
assert.match(html, /Richese player: concealed No-Field/);
assert.match(html, /counts as one force/);
assert.match(html, /Native reserves: 6 normal/);
assert.doesNotMatch(html, /private-token|No-Field 5/);
assert.equal(renderToStaticMarkup(createElement(NoFieldBoardMarkers, { players: game.players })), '');
assert.equal(planetologistNoFieldMarker(game), null);
});
void test('an Arrakis marker stays on the board and does not appear at a Homeworld', () => {
const game = view({ territory: 'arrakeen', sector: 10 });
const board = renderToStaticMarkup(createElement('svg', null,
createElement(NoFieldBoardMarkers, { players: game.players })));
assert.match(board, /concealed No-Field in Arrakeen, sector 10/);
const world = renderToStaticMarkup(createElement(HomeworldTable, { game }));
assert.doesNotMatch(world, /concealed No-Field/);
});
+1
View File
@@ -141,6 +141,7 @@ void test('all profiles move the actual marker explicitly, with no fabricated ph
assert.deepEqual(moved.players[0].forces, {});
assert.equal(moved.players[0].reserves, 20);
assert.equal(moved.players[0].moved, 1);
assert.ok('territory' in moved.players[0].noField!.deployed!.location);
assert.equal(
moved.players[0].noField!.deployed!.location.territory,
action.territory,
+1
View File
@@ -334,6 +334,7 @@ void test('marker movement and concurrent revelation persist once, reject stale
await f.action(f.owner, move, resumed);
const moved = await resumed.readRoom(f.code);
assert.notEqual(moved.players[0].noFieldEvent, oldEvent);
assert.ok('territory' in moved.players[0].noField!.deployed!.location);
assert.equal(
moved.players[0].noField!.deployed!.location.territory,
'arrakeen',
+3
View File
@@ -234,6 +234,7 @@ void test('storm only affects the hit sector and preserves a marker in another s
g = applyAction(g, 'a', { type: 'ready' });
assert.equal(g.players[0].tanks, 2);
assert.equal(g.players[0].reserves, 18);
assert.ok('territory' in g.players[0].noField!.deployed!.location);
assert.equal(g.players[0].noField!.deployed!.location.sector, safe);
let sheltered = marker(fixture(), 3, 'imperial_basin', 10);
sheltered.phase = 0;
@@ -332,6 +333,7 @@ void test('simultaneous entry reactions fail atomically and stale CHOAM continua
sector: 10,
});
assert.equal(moving.decision?.kind, 'choamMovement');
assert.ok('territory' in moving.players[0].noField!.deployed!.location);
assert.equal(
moving.players[0].noField!.deployed!.location.territory,
'imperial_basin',
@@ -341,6 +343,7 @@ void test('simultaneous entry reactions fail atomically and stale CHOAM continua
const result = applyAction(moving, 'a', { type: 'decision', decline: true });
assert.equal(result.pendingChoamMove, null);
assert.equal(result.decision, null);
assert.ok('territory' in result.players[0].noField!.deployed!.location);
assert.equal(
result.players[0].noField!.deployed!.location.territory,
'imperial_basin',
+6
View File
@@ -84,6 +84,7 @@ void test('deserialized inventory, history and deployed references are validated
s.deployed!.controller = '';
},
(s: RicheseNoField) => {
assert.ok('territory' in s.deployed!.location);
s.deployed!.location.sector = 19;
},
(s: RicheseNoField) => {
@@ -164,6 +165,8 @@ void test('moving preserves hidden identity/history and exact sector without mat
territory: 'imperial_basin',
sector: 9,
});
assert.ok('territory' in state.deployed!.location);
assert.ok('territory' in moved.deployed!.location);
assert.equal(state.deployed!.location.sector, 10);
assert.equal(moved.deployed!.location.sector, 9);
assert.equal(moved.lastShipped, ids[2]);
@@ -473,13 +476,16 @@ void test('outputs and projections are independent clones and caller-provided pl
};
const state = deployRicheseNoField(initial, placement);
placement.location.sector = 2;
assert.ok('territory' in state.deployed!.location);
assert.equal(state.deployed!.location.sector, 10);
const own = projectRicheseNoField(state, true);
own.private.tokens[0].id = 'changed';
assert.ok('territory' in own.deployed!.location);
own.deployed!.location.sector = 3;
assert.equal(state.tokens[0].id, ids[0]);
assert.equal(state.deployed!.location.sector, 10);
const result = reveal(state);
assert.ok('territory' in result.location);
result.location.sector = 4;
result.state.tokens.reverse();
assert.equal(state.deployed!.location.sector, 10);
+3 -1
View File
@@ -102,7 +102,9 @@ void test('a mixed physical-plus-marker move fulfills only its physical quantity
noField: f.token, event: player(g, f.target).noFieldEvent, territory: 'arrakeen', sector: 10 });
assert.equal(g.movementPromises![0].fulfilled, true);
assert.equal(player(g, f.target).forces['arrakeen:10'], 3);
assert.equal(player(g, f.target).noField?.deployed?.location.territory, 'arrakeen');
const marker = player(g, f.target).noField?.deployed?.location;
assert.ok(marker && 'territory' in marker);
assert.equal(marker.territory, 'arrakeen');
});
void test('controlled original Karama prevents a native No-Field preparation before payment and releases its now impossible promise', () => {