Support concealed Homeworld transfers and Ecaz victory
This commit is contained in:
1 parent
850227c26d
commit
1f03ae673b
14 files changed
+712
-43
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ecazHomeworldVictoryProgress } from '../game/ecaz-homeworld-victory';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldOccupationMarker, observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
import { deployRicheseNoField, recallRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
import type { Game } from '../game/engine';
|
||||
import { ecazHomeworldFixture, ecazSeat } from './fixture-ecaz-homeworld-victory';
|
||||
|
||||
const world = 'homeworld:emperor';
|
||||
|
||||
/** The original fixture has native setup and a deliberately staged, conserved
|
||||
* victory position. Exchange its one physical visitor for a real hidden token;
|
||||
* no token denomination is added to the Homeworld force ledger. */
|
||||
function replaceVisitorWithMarker(game: Game, value: NoFieldValue) {
|
||||
const owner = ecazSeat(game, 'al');
|
||||
const marker = owner.noField!.tokens.find(token => token.value === value)!;
|
||||
assert.deepEqual(game.homeworlds!.custody!.visitors[world].al, { normal: 1, elite: 0 });
|
||||
delete game.homeworlds!.custody!.visitors[world].al;
|
||||
delete game.homeworlds!.custody!.visitors[world];
|
||||
owner.reserves++;
|
||||
owner.noField = deployRicheseNoField(owner.noField!, {
|
||||
tokenId: marker.id, controller: owner.id, location: { homeworld: world },
|
||||
});
|
||||
observe(game, `controlled-marker-${value}`);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function observe(game: Game, event: string) {
|
||||
homeworldGameIntegrity(game);
|
||||
game.homeworldOccupationHistory = observeHomeworldOccupation(game.homeworldOccupationHistory!,
|
||||
homeworldContext(game), game.homeworlds!.custody!, game.turn, 'change', event,
|
||||
homeworldOccupationMarker(game.players));
|
||||
homeworldGameIntegrity(game);
|
||||
}
|
||||
|
||||
function physicalInventory(game: Game) {
|
||||
const worlds = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
return Object.fromEntries(game.players.map(player => [player.id, player.reserves + player.tanks +
|
||||
Object.values(player.forces).reduce((sum, n) => sum + n, 0) + worlds
|
||||
.filter(home => home.native !== player.id)
|
||||
.reduce((sum, home) => sum + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0)]));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} High Ecaz victory counts an ally's concealed Homeworld marker without its value`, () => {
|
||||
const base = ecazHomeworldFixture(advanced, 'richese').game;
|
||||
const before = ecazHomeworldVictoryProgress(base)!;
|
||||
const inventory = physicalInventory(base);
|
||||
assert.equal(before.qualifies, true);
|
||||
const outcomes = ([0, 3, 5] as const).map(value => {
|
||||
const game = structuredClone(base);
|
||||
const marker = replaceVisitorWithMarker(game, value);
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, true);
|
||||
assert.deepEqual(row.foreignHomeworlds, before.foreignHomeworlds);
|
||||
assert.equal(game.homeworlds!.custody!.visitors[world], undefined);
|
||||
assert.deepEqual(physicalInventory(game), inventory);
|
||||
assert.equal(JSON.stringify(row).includes(marker.id), false);
|
||||
return row;
|
||||
});
|
||||
assert.deepEqual(outcomes[0], outcomes[1]);
|
||||
assert.deepEqual(outcomes[1], outcomes[2]);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} High Ecaz requires a currently present marker even after prior occupation`, () => {
|
||||
const game = ecazHomeworldFixture(advanced, 'richese').game;
|
||||
const marker = replaceVisitorWithMarker(game, 3);
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
|
||||
const owner = ecazSeat(game, 'al');
|
||||
owner.noField = recallRicheseNoField(owner.noField!, marker.id);
|
||||
observe(game, 'controlled-marker-departure');
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, false);
|
||||
assert.deepEqual(row.distinctNativeFactions, ['atreides']);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} an opposing World visitor contests a marker-only ally holding under native occupation rules`, () => {
|
||||
const game = ecazHomeworldFixture(advanced, 'richese').game;
|
||||
replaceVisitorWithMarker(game, 5);
|
||||
const observer = ecazSeat(game, 'observer');
|
||||
assert.ok(observer.reserves > 0);
|
||||
observer.reserves--;
|
||||
game.homeworlds!.custody!.visitors[world] = { observer: { normal: 1, elite: 0 } };
|
||||
observe(game, 'controlled-marker-contest');
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, advanced, 'Advanced keeps its original occupation while Basic requires current sole occupation.');
|
||||
assert.equal(row.blocked, null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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, 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';
|
||||
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);
|
||||
const marker = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
|
||||
{ tokenId: 'five', controller: 'r', location: { homeworld: 'homeworld:atreides' } });
|
||||
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: marker,
|
||||
canShip: false, shipBlock: 'Reveal the existing No-Field before shipping another.' },
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
void test('the owner receives the actual transfer packet although another deployment is blocked', () => {
|
||||
const view = fixture(), before = structuredClone(view);
|
||||
const quote = options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0, 'homeworld:atreides');
|
||||
assert.equal(quote.cost, 1); assert.equal(quote.ownPayment, 1);
|
||||
assert.deepEqual(quote.action, { type: 'homeworldShip', event: 'homeworld-event', destination: 'homeworld:guild',
|
||||
sources: {}, noField: 'five', noFieldEvent: 'marker-event', allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
|
||||
assert.deepEqual(view, before);
|
||||
});
|
||||
|
||||
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, '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 [
|
||||
{ phase: 6 }, { active: 'a' }, { status: 'finished' }, { roomControl: { paused: true } },
|
||||
{ homeworldShipment: { event: 'homeworld-event', blocked: 'Wait.' } },
|
||||
{ richeseNoField: { ...view.richeseNoField!, owner: 'a', private: null } }, { leaderSkills: {} },
|
||||
]) assert.equal(options.richeseHomeworldMarkerChoice({ ...view, ...patch } as GameView,
|
||||
'homeworld:guild', 'five', 0, 'homeworld:atreides').action, null);
|
||||
view.players[0].spice = 0;
|
||||
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0, 'homeworld:atreides').action, null);
|
||||
});
|
||||
|
||||
void test('owner sees the current marker and different destination; all minimal bot levels can offer transfer', () => {
|
||||
const view = fixture();
|
||||
const html = renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: view, act() {}, busy: false }));
|
||||
assert.match(html, /from Caladan/); assert.match(html, /No-Field 5/);
|
||||
assert.doesNotMatch(html, /No-Field 0|No-Field 3/);
|
||||
const foreign = { ...view, me: 'a', richeseNoField: { ...view.richeseNoField!, private: null } } as GameView;
|
||||
assert.equal(renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: foreign, act() {}, busy: false })), '');
|
||||
for (const level of [0, 1, 2, 3]) {
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
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 mounted control submits the current token and source through the normal button', () => {
|
||||
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 Error(`Unexpected import ${name}`);
|
||||
},
|
||||
});
|
||||
assert.ok(exports.HomeworldNoFieldShipment);
|
||||
cursor = 0;
|
||||
const nodes = elements(exports.HomeworldNoFieldShipment({ game: view, busy: false, act: action => sent.push(action) }));
|
||||
const button = nodes.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, noFieldFrom: 'homeworld:atreides' });
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// @dune-suite integration
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
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 } };
|
||||
}
|
||||
|
||||
/** A fresh room uses real human seat credentials and native legal actions.
|
||||
* No server phase, currency, force, token, or card state is manufactured. */
|
||||
void test('authenticated marker transfer retains its hidden identity between foreign Homeworlds', async () => {
|
||||
const host = await request('/api/rooms', undefined, {
|
||||
name: 'Marker origin world', faction: 'atreides', 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 owner = await request(path, undefined, { type: 'join', name: 'Marker owner', faction: 'richese' });
|
||||
assert.equal(owner.status, 200, JSON.stringify(owner.data)); assert.ok(owner.cookie);
|
||||
const guild = await request(path, undefined, { type: 'join', name: 'Marker destination world', faction: 'guild' });
|
||||
assert.equal(guild.status, 200, JSON.stringify(guild.data)); assert.ok(guild.cookie);
|
||||
const seats = [host, owner, guild].map(result => ({ cookie: result.cookie!, id: result.data.me }));
|
||||
const ownerSeat = seats[1], hostSeat = seats[0], guildSeat = seats[2];
|
||||
let view = guild.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;
|
||||
};
|
||||
const read = async (cookie: string) => {
|
||||
const result = await request(path, cookie);
|
||||
assert.equal(result.status, 200, JSON.stringify(result.data));
|
||||
view = result.data; return result.data;
|
||||
};
|
||||
await act(hostSeat.cookie, { type: 'homeworlds', enabled: true });
|
||||
for (const seat of seats) await act(seat.cookie, { type: 'ready' });
|
||||
await act(hostSeat.cookie, { type: 'start' });
|
||||
|
||||
let firstMarkerPlaced = false;
|
||||
async function advance(until: (ownerView: GameView) => boolean) {
|
||||
for (let count = 0; count < 800; count++) {
|
||||
const currentOwner = await read(ownerSeat.cookie);
|
||||
if (until(currentOwner)) return currentOwner;
|
||||
assert.notEqual(currentOwner.status, 'finished', 'The QA game ended before marker transfer.');
|
||||
|
||||
// Atreides sends its actual reserves away from its World, so the
|
||||
// concealed marker can remain there through the next Battle phase.
|
||||
if (currentOwner.status === 'playing' && currentOwner.phase === 5 && currentOwner.active === hostSeat.id &&
|
||||
!currentOwner.response && !currentOwner.decision && !currentOwner.phaseOpening &&
|
||||
!currentOwner.truthtrance) {
|
||||
const at = await read(hostSeat.cookie);
|
||||
const me = at.players.find(player => player.id === hostSeat.id)!;
|
||||
if (!me.shipped && me.reserves > 0) {
|
||||
let accepted = false;
|
||||
for (const territory of TERRITORIES.filter(item => item.type === 'stronghold')) {
|
||||
for (const sector of territory.sectors) {
|
||||
if (sector === at.storm) continue;
|
||||
const attempt = await request(path, hostSeat.cookie, { version: view.version, action: {
|
||||
type: 'ship', territory: territory.id, sector, amount: me.reserves, elite: 0,
|
||||
allyPayment: 0, homeworldSources: { 'homeworld:atreides': { normal: me.reserves, elite: 0 } },
|
||||
} });
|
||||
if (attempt.status === 200) { view = attempt.data; accepted = true; break; }
|
||||
assert.equal(attempt.status, 409, JSON.stringify(attempt.data));
|
||||
}
|
||||
if (accepted) break;
|
||||
}
|
||||
assert.ok(accepted, 'Atreides can ship its physical reserves to an available stronghold.');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let proposed: { cookie: string; actor: string; action: Action } | null = null;
|
||||
for (const seat of seats) {
|
||||
const own = await read(seat.cookie);
|
||||
const local = structuredClone(own);
|
||||
const self = local.players.find(player => player.id === seat.id); assert.ok(self);
|
||||
self.bot = 'Easy'; // Client-side adapter only; authoritative seat stays human.
|
||||
const choices = botActions(local);
|
||||
if (choices.length) { proposed = { cookie: seat.cookie, actor: seat.id, action: choices[0] }; break; }
|
||||
}
|
||||
assert.ok(proposed, `No native continuation at ${currentOwner.status}/${currentOwner.phase}/${currentOwner.decision?.kind ?? 'no decision'}.`);
|
||||
if (view.response && !view.response.passed.includes(view.me)) proposed.action = { type: 'passResponse' };
|
||||
// Keep the actual Atreides shipment affordable and avoid extra foreign
|
||||
// arrivals to the temporary marker World; all passes/skips are legal.
|
||||
else if (proposed.actor === hostSeat.id && proposed.action.type === 'bid') proposed.action = { type: 'passBid' };
|
||||
else if (proposed.actor === hostSeat.id && proposed.action.type === 'richeseBid')
|
||||
proposed.action = { type: 'richeseBid', event: proposed.action.event, amount: 0, allyPayment: 0 };
|
||||
else if (view.phase === 5 && view.active === proposed.actor && !view.response && !view.decision &&
|
||||
!view.truthtrance && !view.phaseOpening &&
|
||||
(proposed.actor === guildSeat.id || proposed.actor === ownerSeat.id && firstMarkerPlaced))
|
||||
proposed.action = { type: 'endMovement' };
|
||||
await act(proposed.cookie, proposed.action);
|
||||
}
|
||||
throw Error('The dedicated HTTP room did not reach the native marker transfer boundary.');
|
||||
}
|
||||
|
||||
const first = await advance(own => own.status === 'playing' && own.phase === 5 && own.active === own.me &&
|
||||
!own.players.find(player => player.id === own.me)!.shipped &&
|
||||
own.players.find(player => player.id === hostSeat.id)!.reserves === 0 &&
|
||||
!!own.homeworldShipment && !own.homeworldShipment.blocked && !!own.richeseNoField?.canShip);
|
||||
const markerInfo = first.richeseNoField!; assert.ok(markerInfo.private);
|
||||
const token = markerInfo.private.tokens.find(candidate => candidate.id !== markerInfo.private!.lastShipped);
|
||||
assert.ok(token);
|
||||
const ids = markerInfo.private.tokens.map(candidate => candidate.id);
|
||||
const initial = richeseHomeworldMarkerChoice(first, 'homeworld:atreides', token.id);
|
||||
assert.ok(initial.action, initial.blocked ?? 'The initial marker shipment is available.');
|
||||
await act(ownerSeat.cookie, initial.action);
|
||||
firstMarkerPlaced = true;
|
||||
const landed = await advance(own => own.phase === 5 && !!own.richeseNoField?.private?.deployed &&
|
||||
own.players.find(player => player.id === own.me)?.shipped === true);
|
||||
assert.deepEqual(landed.richeseNoField!.private!.deployed!.location, { homeworld: 'homeworld:atreides' });
|
||||
const firstTurn = landed.turn;
|
||||
const beforeTransfer = await advance(own => own.status === 'playing' && own.turn > firstTurn && own.phase === 5 &&
|
||||
own.active === own.me && !own.players.find(player => player.id === own.me)!.shipped &&
|
||||
!!own.homeworldShipment && !own.homeworldShipment.blocked && !!own.richeseNoField?.private?.deployed);
|
||||
const mover = beforeTransfer.players.find(player => player.id === beforeTransfer.me)!;
|
||||
const oldSpice = mover.spice, oldReserves = mover.reserves;
|
||||
const transfer = richeseHomeworldMarkerChoice(beforeTransfer, 'homeworld:guild', token.id, 0, 'homeworld:atreides');
|
||||
assert.ok(transfer.action, transfer.blocked ?? 'The marker transfer is available.');
|
||||
assert.equal(transfer.action.noFieldFrom, 'homeworld:atreides');
|
||||
await act(ownerSeat.cookie, transfer.action);
|
||||
const after = await advance(own => own.phase === 5 && own.players.find(player => player.id === own.me)?.shipped === true &&
|
||||
!!own.richeseNoField?.private?.deployed && 'homeworld' in own.richeseNoField.private.deployed.location &&
|
||||
own.richeseNoField.private.deployed.location.homeworld === 'homeworld:guild');
|
||||
const result = after.players.find(player => player.id === after.me)!;
|
||||
assert.equal(result.spice, oldSpice! - 1); assert.equal(result.reserves, oldReserves);
|
||||
assert.equal(after.richeseNoField!.private!.deployed!.tokenId, token.id);
|
||||
assert.equal(after.richeseNoField!.private!.lastShipped, token.id);
|
||||
const other = await read(hostSeat.cookie);
|
||||
assert.equal(other.richeseNoField!.private, null);
|
||||
assert.deepEqual(other.richeseNoField!.public.deployed!.location, { homeworld: 'homeworld:guild' });
|
||||
for (const id of ids) assert.equal(JSON.stringify(other).includes(id), false, 'A foreign seat cannot inspect marker identity.');
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
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, deployRicheseNoField } from '../game/richese-no-field';
|
||||
import { quoteRicheseHomeworldMarkerTransfer as quote } from '../game/richese-homeworld-marker-shipment';
|
||||
|
||||
function fixture(tokenId = 'zero') {
|
||||
const context: HomeworldShipmentContext = { advanced: true, players: [
|
||||
{ id: 'r', faction: 'richese', reserves: 0, 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: { 'homeworld:atreides': { r: { normal: 2, elite: 0 } } } };
|
||||
const noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
|
||||
{ tokenId, controller: 'r', location: { homeworld: 'homeworld:atreides' } });
|
||||
return { context, custody, noField, intent: { player: 'r', tokenId, source: 'homeworld:atreides', destination: 'homeworld:guild' } };
|
||||
}
|
||||
|
||||
void test('each physical marker transfers as one without materialization or shipment-history changes', () => {
|
||||
for (const advanced of [false, true]) for (const tokenId of ['zero', 'three', 'five']) {
|
||||
const f = fixture(tokenId); f.context.advanced = advanced;
|
||||
const before = structuredClone(f), 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:atreides', destination: 'homeworld:guild', amount: 1, elite: 0, cost: 1 });
|
||||
assert.deepEqual(result.noField.deployed, { tokenId, controller: 'r', location: { homeworld: 'homeworld:guild' } });
|
||||
assert.equal(result.noField.lastShipped, tokenId);
|
||||
assert.deepEqual(result.noField.tokens, f.noField.tokens);
|
||||
assert.deepEqual(f, before);
|
||||
result.noField.tokens[0].id = 'detached';
|
||||
assert.deepEqual(f, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('missing, stale, 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.intent.destination = 'homeworld:unknown'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.player = 'a'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.noField.deployed = null; },
|
||||
(f: ReturnType<typeof fixture>) => { f.context.players[0].ally = 'g'; f.context.players[2].ally = 'r'; },
|
||||
(f: ReturnType<typeof fixture>) => { (f.intent as unknown as Record<string, unknown>).extra = true; },
|
||||
]) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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 { TERRITORIES } from '../game/board';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
|
||||
|
||||
const owner = 'richese', native = 'atreides', guild = 'guild';
|
||||
const source = 'homeworld:atreides', destination = 'homeworld:guild';
|
||||
const seat = (game: Game, id: string) => {
|
||||
const result = game.players.find(player => player.id === id);
|
||||
assert.ok(result); return result;
|
||||
};
|
||||
const quiet = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Original native action/response continuations, including a real Guild
|
||||
* interception when Advanced allows one. No saved phase or custody is staged. */
|
||||
function advance(game: Game, until: (game: Game) => boolean): Game {
|
||||
let state = game;
|
||||
for (let count = 0; count < 2500; count++) {
|
||||
if (until(state)) return state;
|
||||
assert.notEqual(state.status, 'finished');
|
||||
if (state.decision?.kind === 'homeworldShipmentGuild') {
|
||||
const decision = state.decision;
|
||||
state = applyAction(state, decision.player, { type: 'decision', event: decision.event, allow: true });
|
||||
continue;
|
||||
}
|
||||
const next = nextIndependentCardModulesStep(state);
|
||||
assert.ok(next, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}.`);
|
||||
// Preserve the native Atreides cash needed for its intentional full
|
||||
// shipment by passing the actual Richese cache lot, rather than buying it.
|
||||
if (next.actor === native && next.action.type === 'richeseBid') {
|
||||
state = applyAction(state, native, { type: 'richeseBid', event: next.action.event, amount: 0, allyPayment: 0 });
|
||||
continue;
|
||||
}
|
||||
state = applyAction(state, next.actor, next.action);
|
||||
}
|
||||
throw Error('The native Homeworld transfer programme did not reach its requested boundary.');
|
||||
}
|
||||
|
||||
/** Actual original lobby/start and force choices. Atreides deliberately ships
|
||||
* its whole real reserve to a legal stronghold before the first marker arrives,
|
||||
* so that the marker survives the intervening Battle phase without inventing
|
||||
* a battle exemption. */
|
||||
function setup(advanced: boolean): Game {
|
||||
let game = createGame('RICHESEWORLDTRANSFER', newPlayer(native, native, 'atreides'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer(owner, owner, 'richese'));
|
||||
joinGame(game, newPlayer(guild, guild, 'guild'));
|
||||
game = applyAction(game, native, { type: 'homeworlds', enabled: true });
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = applyAction(game, native, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
// Select one undealt original card position, ensuring Guild has a real
|
||||
// physical Karama for the response/stop case. Never add or duplicate cards.
|
||||
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(seat(game, guild).hand.some(card => card.effect === 'karama'));
|
||||
game = advance(game, state => state.phase === 5 && state.active === native && quiet(state));
|
||||
const amount = seat(game, native).reserves;
|
||||
assert.ok(amount > 0);
|
||||
let shipped: Game | null = null;
|
||||
for (const target of TERRITORIES.filter(territory => territory.type === 'stronghold')) {
|
||||
for (const sector of target.sectors) {
|
||||
if (sector === game.storm) continue;
|
||||
try {
|
||||
shipped = applyAction(game, native, { type: 'ship', territory: target.id, sector, amount,
|
||||
elite: 0, allyPayment: 0, homeworldSources: { [source]: { normal: amount, elite: 0 } } });
|
||||
break;
|
||||
} catch { /* Try another legal native stronghold; rejected actions are immutable. */ }
|
||||
}
|
||||
if (shipped) break;
|
||||
}
|
||||
assert.ok(shipped, 'Atreides can ship its real starting reserve to a legal stronghold.');
|
||||
game = advance(shipped, quiet);
|
||||
assert.equal(seat(game, native).reserves, 0);
|
||||
game = applyAction(game, native, { type: 'endMovement' });
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
!!viewGame(state, owner).homeworldShipment && !!viewGame(state, owner).richeseNoField?.canShip);
|
||||
return game;
|
||||
}
|
||||
|
||||
function inventory(game: Game) {
|
||||
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
return Object.fromEntries(game.players.map(player => [player.id, player.reserves + player.tanks +
|
||||
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0) + homes
|
||||
.filter(home => home.native !== player.id)
|
||||
.reduce((sum, home) => sum + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0)]));
|
||||
}
|
||||
|
||||
function placeFirstMarker(game: Game) {
|
||||
const own = viewGame(game, owner), info = own.richeseNoField!;
|
||||
const token = info.private!.tokens.find(candidate => candidate.value === 3);
|
||||
assert.ok(token);
|
||||
game = applyAction(game, owner, { type: 'homeworldShip', event: own.homeworldShipment!.event,
|
||||
destination: source, sources: {}, noField: token.id, noFieldEvent: info.event, allyPayment: 0 });
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
seat(state, owner).shipped && !!seat(state, owner).noField?.deployed);
|
||||
assert.deepEqual(seat(game, owner).noField!.deployed!.location, { homeworld: source });
|
||||
assert.equal(seat(game, owner).noField!.lastShipped, token.id);
|
||||
return { game, token: token.id };
|
||||
}
|
||||
|
||||
function nextTransferTurn(game: Game): Game {
|
||||
const turn = game.turn;
|
||||
game = applyAction(game, owner, { type: 'endMovement' });
|
||||
return advance(game, state => state.turn > turn && state.phase === 5 && state.active === owner &&
|
||||
quiet(state) && !seat(state, owner).shipped && !!viewGame(state, owner).homeworldShipment &&
|
||||
!viewGame(state, owner).homeworldShipment!.blocked);
|
||||
}
|
||||
|
||||
function declaration(game: Game, token: string): Action {
|
||||
const own = viewGame(game, owner);
|
||||
return { type: 'homeworldShip', event: own.homeworldShipment!.event, destination, sources: {},
|
||||
noField: token, noFieldEvent: own.richeseNoField!.event, noFieldFrom: source, allyPayment: 0 };
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} native Homeworld marker transfer retains its identity and physical reserves`, () => {
|
||||
const initial = setup(advanced), originalInventory = inventory(initial);
|
||||
const first = placeFirstMarker(initial);
|
||||
let game = nextTransferTurn(first.game);
|
||||
const before = structuredClone(game), action = declaration(game, first.token);
|
||||
const stale = { ...action, noFieldEvent: 'stale-marker-event' };
|
||||
assert.throws(() => applyAction(game, owner, stale));
|
||||
assert.deepEqual(game, before, 'A stale marker event leaves the entire game unchanged.');
|
||||
game = applyAction(game, owner, action);
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
seat(state, owner).shipped && !state.pendingHomeworldShipment);
|
||||
assert.deepEqual(seat(game, owner).noField!.deployed!.location, { homeworld: destination });
|
||||
assert.equal(seat(game, owner).noField!.deployed!.tokenId, first.token);
|
||||
assert.equal(seat(game, owner).noField!.lastShipped, first.token);
|
||||
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
|
||||
assert.equal(seat(game, owner).spice, seat(before, owner).spice - 1);
|
||||
assert.deepEqual(inventory(game), originalInventory);
|
||||
const foreign = viewGame(game, native).richeseNoField!;
|
||||
assert.equal(foreign.private, null);
|
||||
assert.deepEqual(foreign.public.deployed!.location, { homeworld: destination });
|
||||
assert.equal(JSON.stringify(foreign).includes(first.token), false);
|
||||
});
|
||||
|
||||
void test('Advanced Guild can stop the declared Homeworld marker transfer without payment or moving it', () => {
|
||||
const first = placeFirstMarker(setup(true));
|
||||
let game = nextTransferTurn(first.game);
|
||||
const before = structuredClone(game), action = declaration(game, first.token);
|
||||
game = applyAction(game, owner, action);
|
||||
game = advance(game, state => state.decision?.kind === 'homeworldShipmentGuild');
|
||||
const karama = seat(game, guild).hand.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
game = applyAction(game, guild, { type: 'card', mode: 'special', card: karama.id });
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
!state.pendingHomeworldShipment);
|
||||
assert.equal(seat(game, owner).shipped, true);
|
||||
assert.deepEqual(seat(game, owner).noField, seat(before, owner).noField);
|
||||
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
|
||||
assert.equal(seat(game, owner).spice, seat(before, owner).spice);
|
||||
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
|
||||
});
|
||||
Reference in new issue
Block a user