Prototype native Homeworld No-Field return
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
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/);
|
||||
});
|
||||
}
|
||||
@@ -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; },
|
||||
|
||||
Reference in new issue
Block a user