Prototype native Homeworld No-Field return
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 04:55:44 +11:00
1 parent 89569e92a1
commit 7e3d745807
17 files changed
+540 -32

No files matched your search

@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
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 { HomeworldNoFieldShipment } from '../components/homeworld-shipment';
import { createRicheseNoField, deployRicheseNoField } 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';
/** Personalized public Homeworld view with one real deployed marker. */
function fixture(advanced: boolean): GameView {
const context = { advanced, 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: {
'homeworld:atreides': { r: { normal: 2, elite: 0 } },
} };
const populations = homeworldPopulations(context, custody);
const marker = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
{ tokenId: 'five', controller: 'r', location: { homeworld: 'homeworld:atreides' } });
return {
me: 'r', active: 'r', advanced, 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: marker,
canShip: false, shipBlock: 'Reveal the existing No-Field before shipping another.' },
} as unknown as GameView;
}
function markup(view: GameView): string {
return renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: view, act() {}, busy: false }));
}
function destinations(html: string): string {
const match = /<select aria-label="No-Field destination Homeworld"[^>]*>([\s\S]*?)<\/select>/.exec(html);
assert.ok(match, 'The marker destination selector is present');
return match[1];
}
void test('Advanced deployed marker can select native Homeworld without exposing its value to another player', () => {
const view = fixture(true), before = structuredClone(view), html = markup(view);
assert.match(destinations(html), /value="homeworld:richese"/);
assert.doesNotMatch(destinations(html), /value="homeworld:atreides"/);
assert.match(html, /from Caladan/);
assert.match(html, /No-Field 5/);
assert.doesNotMatch(html, /No-Field 0|No-Field 3/);
assert.match(html, /already in those reserves and are not added again/);
const result = options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'five', 0, 'homeworld:atreides');
assert.equal(result.cost, 1);
assert.deepEqual(result.action, { type: 'homeworldShip', event: 'homeworld-event', destination: 'homeworld:richese',
sources: {}, noField: 'five', noFieldEvent: 'marker-event', allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
const observer = { ...view, me: 'a', richeseNoField: { ...view.richeseNoField!, private: null } } as GameView;
assert.equal(markup(observer), '');
assert.deepEqual(view, before);
});
void test('Basic return and fresh Advanced supply placement cannot target the native World', () => {
const basic = fixture(false);
assert.doesNotMatch(destinations(markup(basic)), /value="homeworld:richese"/);
assert.equal(options.richeseHomeworldMarkerChoice(basic, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
const fresh = fixture(true);
fresh.richeseNoField!.private = createRicheseNoField(['zero', 'three', 'five']);
fresh.richeseNoField!.canShip = true;
assert.doesNotMatch(destinations(markup(fresh)), /value="homeworld:richese"/);
assert.equal(options.richeseHomeworldMarkerChoice(fresh, 'homeworld:richese', 'five', 0).action, null);
});
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 marker control sends the selected native return through the normal shipment action', () => {
const view = fixture(true), 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 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 = render().find(element => element.type === 'select' && element.props['aria-label'] === 'No-Field destination Homeworld');
assert.ok(select);
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'homeworld:richese' } });
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:richese', sources: {}, noField: 'five', noFieldEvent: 'marker-event',
allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
});
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { type HomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody';
import type { HomeworldShipmentContext } from '../game/homeworld-shipment';
import { homeworldCombatArmy, homeworldCombatLocation, type HomeworldCombatContext } from '../game/homeworld-combat';
import { currentHomeworldOccupationSnapshot, homeworldOccupationSnapshotPresence } from '../game/homeworld-occupation-history';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
import { quoteRicheseHomeworldMarkerShipment, quoteRicheseHomeworldMarkerTransfer } from '../game/richese-homeworld-marker-shipment';
import { homeworldNoFieldPresence, quoteHomeworldBattlesWithNoField } from '../game/richese-homeworld-marker-combat';
const native = 'homeworld:richese';
const foreign = 'homeworld:atreides';
const other = 'homeworld:guild';
function fixture(tokenId = 'zero') {
const context: HomeworldShipmentContext = { advanced: true, players: [
{ id: 'r', faction: 'richese', reserves: 7, eliteReserves: 0, ally: null },
{ id: 'a', faction: 'atreides', reserves: 17, eliteReserves: 0, ally: null },
{ id: 'g', faction: 'guild', reserves: 20, eliteReserves: 0, ally: null },
] };
const custody: HomeworldCustody = { salusa: null, visitors: { [foreign]: { r: { normal: 2, elite: 0 } } } };
const noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
{ tokenId, controller: 'r', location: { homeworld: foreign } });
return { context, custody, noField };
}
for (const tokenId of ['zero', 'three', 'five']) {
void test(`Advanced native marker return and later exit preserve ${tokenId} without materializing it`, () => {
const f = fixture(tokenId), before = structuredClone(f);
const returnQuote = quoteRicheseHomeworldMarkerTransfer(f.context, f.custody,
{ player: 'r', tokenId, source: foreign, destination: native, forces: { normal: 2, elite: 0 } }, f.noField);
assert.equal(returnQuote.amount, 3);
assert.equal(returnQuote.cost, 3);
assert.equal(returnQuote.players!.find(player => player.id === 'r')!.reserves, 9);
assert.equal(returnQuote.state!.visitors[foreign]?.r, undefined);
assert.deepEqual(returnQuote.noField.deployed, { tokenId, controller: 'r', location: { homeworld: native } });
assert.equal(returnQuote.noField.lastShipped, tokenId);
assert.deepEqual(returnQuote.noField.tokens, f.noField.tokens);
assert.deepEqual(f, before);
const context: HomeworldShipmentContext = { ...f.context, players: f.context.players.map(seat => {
const next = returnQuote.players!.find(player => player.id === seat.id)!;
return { ...seat, reserves: next.reserves, eliteReserves: next.eliteReserves };
}) };
const exit = quoteRicheseHomeworldMarkerTransfer(context, returnQuote.state!,
{ player: 'r', tokenId, source: native, destination: other, forces: { normal: 2, elite: 0 } }, returnQuote.noField);
assert.equal(exit.amount, 3);
assert.equal(exit.cost, 3);
assert.equal(exit.players!.find(player => player.id === 'r')!.reserves, 7);
assert.deepEqual(exit.state!.visitors[other].r, { normal: 2, elite: 0 });
assert.deepEqual(exit.noField.deployed, { tokenId, controller: 'r', location: { homeworld: other } });
assert.equal(exit.noField.lastShipped, tokenId);
assert.deepEqual(exit.noField.tokens, f.noField.tokens);
});
}
void test('Basic keeps foreign-only marker endpoints and fresh native placement remains unavailable', () => {
const f = fixture(), basic = { ...f.context, advanced: false };
const intent = { player: 'r', tokenId: 'zero', source: foreign, destination: native };
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(basic, f.custody, intent, f.noField), HomeworldCustodyError);
const nativeMarker = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
{ tokenId: 'zero', controller: 'r', location: { homeworld: native } });
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(basic, f.custody,
{ player: 'r', tokenId: 'zero', source: native, destination: other }, nativeMarker), HomeworldCustodyError);
assert.throws(() => quoteRicheseHomeworldMarkerShipment(f.context, f.custody,
{ player: 'r', tokenId: 'three', destination: native }, createRicheseNoField(['zero', 'three', 'five'])), HomeworldCustodyError);
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(f.context, f.custody,
{ player: 'r', tokenId: 'zero', source: foreign, destination: foreign }, f.noField), HomeworldCustodyError);
const allied: HomeworldShipmentContext = { ...f.context, players: f.context.players.map(seat =>
seat.id === 'r' ? { ...seat, ally: 'g' } : seat.id === 'g' ? { ...seat, ally: 'r' } : seat) };
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(allied, f.custody,
{ player: 'r', tokenId: 'zero', source: foreign, destination: other }, f.noField), HomeworldCustodyError);
});
for (const reserves of [0, 7]) {
void test(`Advanced native marker preserves physical population ${reserves} and adds only presence`, () => {
const f = fixture();
f.context.players[0].reserves = reserves;
f.custody.visitors[native] = { a: { normal: 1, elite: 0 } };
const context: HomeworldCombatContext = { ...f.context, order: ['r', 'a', 'g'] };
const marker = { controller: 'r', location: { homeworld: native } };
const before = structuredClone({ context, custody: f.custody });
assert.deepEqual(homeworldNoFieldPresence(context, f.custody, marker),
{ controller: 'r', homeworld: native, effectiveForces: 1 });
assert.equal(homeworldCombatLocation(context, f.custody, native).population, reserves);
assert.equal(homeworldCombatArmy(context, f.custody, native, 'r').normal, reserves);
const battles = quoteHomeworldBattlesWithNoField(context, f.custody, marker);
assert.equal(battles.filter(battle => battle.territory === native && battle.attacker === 'r' && battle.defender === 'a').length, 1);
const snapshot = currentHomeworldOccupationSnapshot(context, f.custody,
{ homeworld: native, controller: 'r' });
assert.deepEqual(homeworldOccupationSnapshotPresence(snapshot, native), ['a', 'r']);
assert.deepEqual({ context, custody: f.custody }, before);
const basic = { ...context, advanced: false };
assert.throws(() => homeworldNoFieldPresence(basic, f.custody, marker), /own Homeworld/);
assert.throws(() => currentHomeworldOccupationSnapshot(basic, f.custody,
{ homeworld: native, controller: 'r' }), /foreign world/);
});
}
+231
View File
@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { homeworldShipmentActions, homeworldShipmentChoice, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
import { finishHarassPreparation } from './fixture-harass-withdraw';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { currentHomeworldOccupationSnapshot, homeworldOccupationMarker,
homeworldOccupationSnapshotPresence } from '../game/homeworld-occupation-history';
import {
createRicheseHomeworldShipmentFixture as fixture,
passRicheseHomeworldShipment as pass,
richeseHomeworldShipmentInventory as inventory,
richeseHomeworldShipmentPlayer as player,
} from './fixture-richese-homeworld-shipment';
const owner = 'richese', foreign = 'homeworld:emperor', native = 'homeworld:richese';
function group(game: Game, location: string) {
const world = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(item => item.id === location); assert.ok(world);
return world.forces[owner] ?? {normal:0, elite:0};
}
/** Start from real public Advanced setup and native phase actions. Only a later
* fresh shipment opportunity and wallet are staged after the real first entry;
* the intervening battle is intentionally skipped, so this is not a full game. */
function stageNewTurn(game: Game) {
game.turn++;
Object.assign(game, { phase:5, phaseOpening:null, response:null, decision:null,
active:owner, order:game.players.map(p=>p.id),
movementRemaining:game.players.map(p=>p.id), ready:[] });
for (const p of game.players) { p.shipped=false; p.moved=0; }
player(game, owner).spice = 8;
homeworldGameIntegrity(game);
}
for (const value of [0,3,5] as const) void test(`Advanced concealed ${value} marker returns to its native World without materializing reserves`, () => {
const f = fixture({advanced:true, companions:2, value});
let game = f.game;
const initialInventory = inventory(game), original = structuredClone(game);
const view = viewGame(game,owner), token = view.richeseNoField!.private!.tokens.find(row=>row.value===value);
assert.ok(token);
const fresh = richeseHomeworldMarkerChoice(view, foreign, token.id, 0, undefined, {normal:2, elite:0});
assert.ok(fresh.action, fresh.blocked ?? 'The actual foreign entry must be available');
assert.equal(fresh.cost,3);
game = pass(applyAction(game,owner,fresh.action));
assert.ok(!game.pendingHomeworldShipment && player(game,owner).shipped);
assert.deepEqual(player(game,owner).noField!.deployed!.location,{homeworld:foreign});
assert.deepEqual(group(game,foreign),{normal:2,elite:0});
assert.equal(player(game,owner).reserves,player(original,owner).reserves-2);
assert.deepEqual(inventory(game),initialInventory);
stageNewTurn(game);
const before = structuredClone(game), beforeOwner = viewGame(game,owner);
const returning = richeseHomeworldMarkerChoice(beforeOwner,native,token.id,0,foreign,{normal:1,elite:0});
assert.ok(returning.action,returning.blocked ?? 'A distinct foreign-to-native return must be offered');
assert.equal(returning.amount,2);
assert.equal(returning.cost,2);
let returned = pass(applyAction(game,owner,returning.action));
assert.deepEqual(game,before);
assert.equal(player(returned,owner).spice,player(before,owner).spice-2);
assert.equal(player(returned,owner).reserves,player(before,owner).reserves+1);
assert.deepEqual(group(returned,foreign),{normal:1,elite:0});
assert.equal(player(returned,owner).noField!.deployed!.tokenId,token.id);
assert.deepEqual(player(returned,owner).noField!.deployed!.location,{homeworld:native});
assert.equal(player(returned,owner).noField!.lastShipped,token.id);
const observer = viewGame(returned,'emperor').richeseNoField!;
assert.equal(observer.private,null);
assert.deepEqual(observer.public.deployed!.location,{homeworld:native});
assert.equal(observer.public.deployed!.effectiveForces,1);
assert.equal(JSON.stringify(observer.public).includes(token.id),false);
assert.deepEqual(inventory(returned),initialInventory);
homeworldGameIntegrity(returned);
// A later ordinary shipment can move the same still-concealed marker back
// out of its native World with another physically paid companion.
let outgoing = structuredClone(returned);
stageNewTurn(outgoing);
const departure = richeseHomeworldMarkerChoice(viewGame(outgoing,owner),foreign,token.id,0,native,
{normal:1,elite:0});
assert.ok(departure.action, departure.blocked ?? 'The marker can depart its own World');
assert.equal(departure.cost,2);
const nativeBeforeDeparture = player(outgoing,owner).reserves;
outgoing = pass(applyAction(outgoing,owner,departure.action));
assert.deepEqual(player(outgoing,owner).noField!.deployed!.location,{homeworld:foreign});
assert.equal(player(outgoing,owner).reserves,nativeBeforeDeparture-1);
assert.deepEqual(group(outgoing,foreign),{normal:2,elite:0});
assert.deepEqual(inventory(outgoing),initialInventory);
homeworldGameIntegrity(outgoing);
const reserves = player(returned,owner).reserves, own = player(returned,owner);
returned = pass(applyAction(returned,owner,{type:'revealNoField',token:token.id,event:own.noFieldEvent}));
assert.equal(player(returned,owner).noField!.deployed,null);
assert.equal(player(returned,owner).reserves,reserves,
'Revealing at home does not withdraw or duplicate the native reserve pool.');
assert.deepEqual(group(returned,foreign),{normal:1,elite:0});
assert.deepEqual(inventory(returned),initialInventory);
homeworldGameIntegrity(returned);
});
/** Stages the later phase opportunities, but uses real native shipments,
* battle selection, preparation and plan actions. The native reserves and the
* invading group remain the original physical counters. */
for (const value of [0,3,5] as const) void test(`Advanced native battle counts a home No-Field ${value} without duplicating reserves`, () => {
const f = fixture({advanced:true, companions:2, value});
let game = f.game;
const originalInventory = inventory(game), own = viewGame(game,owner);
const token = own.richeseNoField!.private!.tokens.find(row=>row.value===value); assert.ok(token);
const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0});
assert.ok(first.action,first.blocked ?? 'The original marker deployment must be legal');
game = pass(applyAction(game,owner,first.action));
stageNewTurn(game);
const back = richeseHomeworldMarkerChoice(viewGame(game,owner),native,token.id,0,foreign,{normal:1,elite:0});
assert.ok(back.action,back.blocked ?? 'The native return must be legal');
game = pass(applyAction(game,owner,back.action));
const nativeReserves = player(game,owner).reserves;
const nativePhysical = group(game,native);
assert.equal(nativePhysical.normal,nativeReserves);
// Stage Emperor's next real World shipment from its original reserve pool.
game.turn++;
Object.assign(game,{phase:5,phaseOpening:null,response:null,decision:null,
active:'emperor',order:game.players.map(p=>p.id),
movementRemaining:game.players.map(p=>p.id),ready:[]});
for (const p of game.players) {p.shipped=false;p.moved=0;}
player(game,'emperor').spice = 10;
const invasion = homeworldShipmentChoice(viewGame(game,'emperor'),native,
{'homeworld:emperor':{normal:1,elite:0}});
assert.ok(invasion.action,invasion.blocked ?? 'An actual native World invasion must be legal');
game = pass(applyAction(game,'emperor',invasion.action));
assert.deepEqual(player(game,owner).noField!.deployed!.location,{homeworld:native});
homeworldGameIntegrity(game);
// The Battle phase is staged, then its ordinary chooser and simultaneous
// plans execute natively rather than constructing a saved battle object.
Object.assign(game,{phase:6,active:owner,ready:[],response:null,decision:null,phaseOpening:null});
const choice = viewGame(game,owner).battleChoices.find(row=>row.territory===native);
assert.ok(choice, 'The public home marker and invader create a native battle');
const target = choice.attacker === owner ? choice.defender : choice.attacker;
game = applyAction(game,owner,{type:'chooseBattle',territory:native,target});
const preview = viewGame(game,owner).battle!.ownForces!;
assert.equal(preview.normal,nativePhysical.normal,
'The native army is its existing physical pool, regardless of hidden marker value.');
assert.equal(preview.elite,nativePhysical.elite);
game = finishHarassPreparation(game);
for (const actor of [owner,'emperor']) {
const leader = player(game,actor).leaders.find(row=>!row.dead && (!row.usedAt || row.usedAt===native));
assert.ok(leader);
game = applyAction(game,actor,{type:'battlePlan',dial:0,support:0,leader:leader.id,weapon:null,defense:null});
}
assert.equal(game.battle!.revealed,true);
assert.equal(player(game,owner).noField!.deployed,null);
assert.equal(player(game,owner).reserves,nativeReserves);
assert.deepEqual(group(game,native),nativePhysical);
assert.deepEqual(inventory(game),originalInventory);
homeworldGameIntegrity(game);
});
void test('a concealed native zero marker with no reserves creates public battle presence but no phantom force', () => {
const f = fixture({advanced:true, companions:2, value:0});
let game = f.game;
const originalInventory = inventory(game), own = viewGame(game,owner);
const token = own.richeseNoField!.private!.tokens.find(row=>row.value===0); assert.ok(token);
const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0});
assert.ok(first.action, first.blocked ?? 'The initial deployment must be legal');
game = pass(applyAction(game,owner,first.action));
stageNewTurn(game);
const back = richeseHomeworldMarkerChoice(viewGame(game,owner),native,token.id,0,foreign,{normal:1,elite:0});
assert.ok(back.action, back.blocked ?? 'The native return must be legal');
game = pass(applyAction(game,owner,back.action));
// Explicit physical staging: move all native reserves into the Tanks while
// preserving the real returned marker and the one foreign garrison counter.
const richese = player(game,owner);
richese.tanks += richese.reserves;
richese.reserves = 0;
assert.deepEqual(group(game,native),{normal:0,elite:0});
assert.deepEqual(inventory(game),originalInventory);
homeworldGameIntegrity(game);
game.turn++;
Object.assign(game,{phase:5,phaseOpening:null,response:null,decision:null,
active:'emperor',order:game.players.map(p=>p.id),
movementRemaining:game.players.map(p=>p.id),ready:[]});
for (const p of game.players) {p.shipped=false;p.moved=0;}
player(game,'emperor').spice = 10;
const invasion = homeworldShipmentChoice(viewGame(game,'emperor'),native,
{'homeworld:emperor':{normal:1,elite:0}});
assert.ok(invasion.action,invasion.blocked ?? 'The real invading force must be legal');
game = pass(applyAction(game,'emperor',invasion.action));
assert.deepEqual(group(game,native),{normal:0,elite:0});
assert.equal(viewGame(game,'emperor').richeseNoField!.public.deployed!.effectiveForces,1);
const withMarker = currentHomeworldOccupationSnapshot(homeworldContext(game), game.homeworlds!.custody!,
homeworldOccupationMarker(game.players));
assert.deepEqual(homeworldOccupationSnapshotPresence(withMarker,native),['emperor','richese'],
'The native marker keeps the invading force from being alone before reveal.');
Object.assign(game,{phase:6,active:owner,ready:[],response:null,decision:null,phaseOpening:null});
const choice = viewGame(game,owner).battleChoices.find(row=>row.territory===native);
assert.ok(choice, 'The concealed marker remains a public battle participant even with zero native reserves');
const target = choice.attacker === owner ? choice.defender : choice.attacker;
game = applyAction(game,owner,{type:'chooseBattle',territory:native,target});
assert.equal(viewGame(game,owner).battle!.ownForces!.normal,0);
assert.equal(viewGame(game,owner).battle!.ownForces!.elite,0);
game = finishHarassPreparation(game);
for (const actor of [owner,'emperor']) {
const leader = player(game,actor).leaders.find(row=>!row.dead && (!row.usedAt || row.usedAt===native));
assert.ok(leader);
game = applyAction(game,actor,{type:'battlePlan',dial:0,support:0,leader:leader.id,weapon:null,defense:null});
}
assert.equal(game.battle!.revealed,true);
assert.equal(player(game,owner).noField!.deployed,null);
assert.equal(player(game,owner).reserves,0);
assert.deepEqual(group(game,native),{normal:0,elite:0});
const afterReveal = currentHomeworldOccupationSnapshot(homeworldContext(game), game.homeworlds!.custody!,
homeworldOccupationMarker(game.players));
assert.deepEqual(homeworldOccupationSnapshotPresence(afterReveal,native),['emperor'],
'The removed zero marker creates no physical native presence after reveal.');
assert.deepEqual(inventory(game),originalInventory);
homeworldGameIntegrity(game);
});
void test('all four legal bot profiles offer a foreign-to-native concealed marker return', () => {
const f = fixture({advanced:true, companions:2, value:3});
let game = f.game;
const own = viewGame(game,owner), token = own.richeseNoField!.private!.tokens.find(row=>row.value===3);
assert.ok(token);
const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0});
assert.ok(first.action, first.blocked ?? 'The original marker deployment must be legal');
game = pass(applyAction(game,owner,first.action));
stageNewTurn(game);
for (let level=0; level<4; level++) {
const view = viewGame(game,owner);
const action = homeworldShipmentActions(view,level).find(candidate => candidate.type === 'homeworldShip' &&
candidate.destination === native && candidate.noFieldFrom === foreign && candidate.noField === token.id);
assert.ok(action, `Profile ${level} should offer the legal marker return`);
assert.doesNotThrow(() => applyAction(game,owner,action));
}
});
@@ -79,7 +79,8 @@ void test('a marker needs its seated Richese controller and a foreign active Hom
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,
const basic = { ...context, advanced: false };
assert.throws(() => homeworldNoFieldPresence(basic, createHomeworldCustody(basic),
{ ...marker, location: { homeworld: 'homeworld:richese' } }), /own Homeworld/);
assert.throws(() => homeworldNoFieldPresence(context, custody,
{ ...marker, location: { homeworld: 'homeworld:fremen' } }), /canonical Homeworld/);
@@ -48,7 +48,8 @@ void test('the owner receives the actual transfer packet although another deploy
void test('transfer quote enforces current custody, active timing, destination and funding', () => {
const view = fixture();
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'five', 0, 'homeworld:atreides').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
assert.equal(options.richeseHomeworldMarkerChoice({ ...view, advanced: false }, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
assert.ok(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'three', 0, 'homeworld:atreides').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0, 'homeworld:unknown').action, null);
for (const patch of [
@@ -72,7 +73,7 @@ void test('owner sees the current marker and different destination; all minimal
const action = options.homeworldShipmentActions(view, level).find(candidate => candidate.noFieldFrom !== undefined);
assert.ok(action, `profile ${level} can transport the actual marker`);
assert.equal(action.noFieldFrom, 'homeworld:atreides'); assert.equal(action.noField, 'five');
assert.equal(action.destination, 'homeworld:guild');
assert.ok(['homeworld:guild', 'homeworld:richese'].includes(String(action.destination)));
}
});
@@ -33,12 +33,12 @@ void test('each physical marker transfers as one without materialization or ship
}
});
void test('missing, stale, own-world, same-world, allied and foreign marker requests reject immutably', () => {
void test('missing, stale, Basic own-world, same-world, allied and foreign marker requests reject immutably', () => {
for (const change of [
(f: ReturnType<typeof fixture>) => { f.intent.tokenId = 'three'; },
(f: ReturnType<typeof fixture>) => { f.intent.source = 'homeworld:guild'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:atreides'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:richese'; },
(f: ReturnType<typeof fixture>) => { f.context.advanced = false; f.intent.destination = 'homeworld:richese'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:unknown'; },
(f: ReturnType<typeof fixture>) => { f.intent.player = 'a'; },
(f: ReturnType<typeof fixture>) => { f.noField.deployed = null; },