Support Advanced native Homeworld return
Verify and publish checkpoint container / container (push) Successful in 11m10s

This commit is contained in:
Quintine committed 2026-10-29 04:48:47 +11:00
1 parent 1cf90f1328
commit 89569e92a1
14 files changed
+429 -39

No files matched your search

@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import { HomeworldShipment } from '../components/homeworld-shipment';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
import { homeworldPopulations } from '../game/homeworld-population';
/** Personalized public view: the Atreides player has an actual physical
* foreign garrison. Native and foreign pools are projected using the same
* Homeworld helpers as the existing shipment-control fixture. */
function fixture(advanced: boolean): GameView {
const context = { advanced, players: [
{ id: 'a', faction: 'atreides' as const, reserves: 17, eliteReserves: 0, ally: null },
{ id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null },
] };
const custody: HomeworldCustody = { salusa: null,
visitors: { 'homeworld:guild': { a: { normal: 3, elite: 0 } } } };
const populations = homeworldPopulations(context, custody);
return {
me: 'a', active: 'a', advanced, status: 'playing', phase: 5,
homeworldShipment: { event: 'shipment:1:a', blocked: null },
aid: { available: 0, pledged: 0 },
players: context.players.map(player => ({ ...player, name: player.faction,
spice: player.id === 'a' ? 10 : undefined, shipped: false,
elites: { reserves: player.eliteReserves } })),
homeworlds: { worlds: homeworldForceGroups(context, custody).map(world => ({
...world, ...populations.find(row => row.location === world.id)!,
})) },
} as unknown as GameView;
}
function markup(game: GameView): string {
return renderToStaticMarkup(createElement(HomeworldShipment, { game, busy: false, act() {} }));
}
function destinationOptions(html: string): string {
const options = /<select aria-label="Destination Homeworld"[^>]*>([\s\S]*?)<\/select>/.exec(html);
assert.ok(options, 'The native Homeworld shipment destination selector is present');
return options[1];
}
void test('Advanced personal shipment exposes the own Homeworld, source distinction and price', () => {
const game = fixture(true), before = structuredClone(game), html = markup(game);
const options = destinationOptions(html);
assert.match(options, /value="homeworld:atreides"[^>]*>Caladan · return to your Homeworld/);
assert.match(options, /value="homeworld:guild"/);
assert.match(html, /You may return a foreign garrison to your own Homeworld/);
assert.match(html, /Native reserves and foreign garrisons have separate source pools/);
assert.match(html, /Pay one spice per force/);
const quote = homeworldShipmentChoice(game, 'homeworld:atreides',
{ 'homeworld:guild': { normal: 2, elite: 0 } });
assert.equal(quote.blocked, null);
assert.equal(quote.cost, 2);
assert.equal(quote.action?.destination, 'homeworld:atreides');
assert.deepEqual(game, before);
});
void test('Basic personal shipment retains the explicit native destination restriction', () => {
const game = fixture(false), html = markup(game);
const options = destinationOptions(html);
assert.doesNotMatch(options, /value="homeworld:atreides"/);
assert.match(options, /value="homeworld:guild"/);
assert.match(html, /Your own Homeworld cannot be a destination/);
const quote = homeworldShipmentChoice(game, 'homeworld:atreides',
{ 'homeworld:guild': { normal: 2, elite: 0 } });
assert.equal(quote.action, null);
assert.match(quote.blocked ?? '', /Basic play.*own Homeworld/);
});
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { homeworldShipmentActions, homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
const owner = (g: Game) => g.players.find(p => p.id === 'emperor')!;
const groups = (g: Game) => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!);
const forces = (g: Game, id: string) => groups(g).find(world => world.id === id)!.forces.emperor ?? {normal:0, elite:0};
const total = (g: Game) => {
const p = owner(g);
return p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0) +
groups(g).filter(world => world.native !== p.id).reduce((sum, world) => {
const f = world.forces[p.id]; return sum + (f ? f.normal + f.elite : 0);
}, 0);
};
/** Advance genuine Homeworld setup; only the Shipment and Movement opportunity
* is staged. The foreign garrison is created through an actual shipment. */
function setup(advanced: boolean, actor: 'emperor' | 'guild' = 'emperor') {
let game = createGame('RETURNWORLD', newPlayer(actor, actor, actor), advanced);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
game = applyAction(game, actor, {type:'homeworlds', enabled:true});
for (const seat of game.players) game = applyAction(game, seat.id, {type:'ready'});
game = initializeHomeworldGameForAudit(game);
for (let step = 0; game.status === 'setup' && step < 80; step++) {
let next: Game | undefined;
for (const seat of game.players) {
const view = viewGame(game, seat.id);
view.players.find(p => p.id === seat.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = applyAction(game, seat.id, action); break; }
}
assert.ok(next, 'Native setup needs a legal next action'); game = next;
}
assert.equal(game.status, 'playing');
for (const p of game.players) game.deck.push(...p.hand.splice(0));
const stageOpportunity = (state: Game) => {
Object.assign(state, {phase:5, phaseOpening:null, response:null, decision:null,
active:actor, order:state.players.map(p=>p.id),
movementRemaining:state.players.map(p=>p.id), ready:[], storm:18});
for (const p of state.players) {p.shipped=false; p.moved=0;}
};
stageOpportunity(game);
const initialReserves = game.players.find(p => p.id === actor)!.reserves;
const first = homeworldShipmentChoice(viewGame(game,actor),'homeworld:atreides',
{[`homeworld:${actor}`]:{normal:4,elite:0}});
assert.ok(first.action, first.blocked ?? 'The initial invasion must be legal');
game = applyAction(game,actor,first.action);
assert.deepEqual(groups(game).find(w => w.id === 'homeworld:atreides')!.forces[actor],{normal:4,elite:0});
assert.equal(game.players.find(p => p.id === actor)!.reserves,initialReserves - 4);
if (actor === 'emperor') assert.equal(total(game),20);
game.turn++;
stageOpportunity(game);
homeworldGameIntegrity(game);
return game;
}
for (const advanced of [false,true]) void test(`${advanced ? 'Advanced' : 'Basic'} foreign Homeworld return to native reserves`, () => {
const before = setup(advanced);
const source = {'homeworld:atreides':{normal:2,elite:0}};
const raw: Action = {type:'homeworldShip', event:viewGame(before,'emperor').homeworldShipment!.event,
destination:'homeworld:emperor', sources:source};
const offer = homeworldShipmentChoice(viewGame(before,'emperor'), 'homeworld:emperor', source);
if (!advanced) {
assert.equal(offer.action,null);
const snapshot = structuredClone(before);
assert.throws(() => applyAction(before,'emperor',raw));
assert.deepEqual(before,snapshot);
return;
}
assert.ok(offer.action, offer.blocked ?? 'Advanced return should be offered');
assert.equal(offer.cost,2);
const original = structuredClone(before);
let after = applyAction(before,'emperor',offer.action);
assert.deepEqual(before,original);
assert.equal(owner(after).spice,owner(before).spice-2);
assert.equal(owner(after).reserves,owner(before).reserves+2);
assert.deepEqual(forces(after,'homeworld:atreides'),{normal:2,elite:0});
assert.deepEqual(forces(after,'homeworld:emperor'),{normal:13,elite:0});
assert.equal(owner(after).shipped,true);
assert.equal(owner(after).moved,0);
assert.equal(total(after),20);
homeworldGameIntegrity(after);
assert.throws(() => applyAction(after,'emperor',raw), 'The used/stale shipment cannot be replayed');
after = applyAction(after,'emperor',{type:'endMovement'});
assert.notEqual(after.active,'emperor');
homeworldGameIntegrity(after);
});
void test('all bot profiles retain legal movement participation in the Advanced return opportunity', () => {
const game = setup(true);
for (const [level, difficulty] of DIFFICULTIES.entries()) {
const view = viewGame(game,'emperor'); view.players.find(p=>p.id==='emperor')!.bot=difficulty;
const action = botActions(view)[0]; assert.ok(action, `${difficulty} must have a legal action`);
assert.doesNotThrow(() => applyAction(game,'emperor',action));
const returned = homeworldShipmentActions(view,level).find(candidate =>
candidate.type === 'homeworldShip' && candidate.destination === 'homeworld:emperor');
assert.ok(returned, `${difficulty} must offer a legal native return`);
assert.doesNotThrow(() => applyAction(game,'emperor',returned));
}
});
for (const advanced of [false,true]) for (const canceled of [false,true])
void test(`${advanced ? 'Advanced' : 'Basic'} Guild native return ${canceled ? 'with canceled transport' : 'with native transport'}`, () => {
const game = setup(advanced,'guild');
if (canceled) game.guildTransportBlocked = {turn:game.turn,player:'guild',opportunity:'ordinary'};
const view = viewGame(game,'guild');
const source = {'homeworld:atreides':{normal:2,elite:0}};
const offer = homeworldShipmentChoice(view, 'homeworld:guild', source);
const raw: Action = {type:'homeworldShip',event:view.homeworldShipment!.event,
destination:'homeworld:guild',sources:source};
if (canceled && !advanced) {
assert.equal(offer.action,null);
const snapshot=structuredClone(game);
assert.throws(() => applyAction(game,'guild',raw));
assert.deepEqual(game,snapshot);
return;
}
assert.ok(offer.action,offer.blocked ?? 'Guild return should be offered');
assert.equal(offer.cost,1, 'Native Guild rate remains distinct from transport permission');
const before = structuredClone(game);
const result = applyAction(game,'guild',offer.action);
assert.deepEqual(game,before);
const guild = result.players.find(p=>p.id==='guild')!;
assert.equal(guild.reserves,game.players.find(p=>p.id==='guild')!.reserves+2);
assert.equal(guild.spice,game.players.find(p=>p.id==='guild')!.spice-1);
assert.deepEqual(groups(result).find(w=>w.id==='homeworld:atreides')!.forces.guild,{normal:2,elite:0});
assert.equal(guild.shipped,true);
homeworldGameIntegrity(result);
});
void test('an already-open native Guild Homeworld return response survives the Advanced cutover', () => {
// The response is obtained through the native Basic declaration, then staged
// into an otherwise real Advanced game. Rebind only its source event to the
// Advanced context; no counters, spice or physical cards are manufactured.
const withKarama = (g: Game) => {
const card = g.deck.find(c => c.effect === 'karama'); assert.ok(card);
g.deck.splice(g.deck.indexOf(card),1);
g.players.find(p => p.id === 'atreides')!.hand.push(card);
return card.id;
};
const basic = setup(false,'guild');
withKarama(basic);
const offer = homeworldShipmentChoice(viewGame(basic,'guild'), 'homeworld:guild',
{'homeworld:atreides':{normal:2,elite:0}});
assert.ok(offer.action,offer.blocked ?? 'The original native return must be available');
const declared = applyAction(basic,'guild',offer.action);
assert.equal(declared.response?.kind,'guildTransportPermission');
assert.ok(declared.pendingHomeworldShipment?.guildTransportEvent);
const advanced = setup(true,'guild');
withKarama(advanced);
const event = viewGame(advanced,'guild').homeworldShipment!.event;
advanced.pendingHomeworldShipment = structuredClone(declared.pendingHomeworldShipment!);
advanced.pendingHomeworldShipment.event = event;
advanced.response = structuredClone(declared.response!);
let result = JSON.parse(JSON.stringify(advanced)) as Game;
for (let n=0; result.response && n<20; n++) {
const seat = result.players.find(p => !result.response!.passed.includes(p.id));
assert.ok(seat, 'The saved native response must be passable');
result = applyAction(result,seat.id,{type:'passResponse'});
}
assert.equal(result.response,null);
assert.equal(result.pendingHomeworldShipment,null);
assert.equal(result.players.find(p=>p.id==='guild')!.reserves,
advanced.players.find(p=>p.id==='guild')!.reserves+2);
assert.equal(result.players.find(p=>p.id==='guild')!.spice,
advanced.players.find(p=>p.id==='guild')!.spice-1);
assert.deepEqual(groups(result).find(w=>w.id==='homeworld:atreides')!.forces.guild,{normal:2,elite:0});
homeworldGameIntegrity(result);
});
+6 -6
View File
@@ -105,13 +105,13 @@ void test('Homeworld public shipment quote preserves exact native and foreign so
assert.deepEqual(view, before);
});
void test('public shipment options reject own, allied, depleted, mixed foreign, stale and unfunded orders', () => {
void test('public shipment options allow Advanced native return and reject allied, depleted, mixed foreign, stale and unfunded orders', () => {
const { view } = fixture();
const good = { 'homeworld:emperor': { normal: 2, elite: 0 } };
assert.equal(
homeworldShipmentChoice(view, 'homeworld:emperor:salusa', good).action,
null,
);
const nativeReturn = homeworldShipmentChoice(view, 'homeworld:emperor:salusa', good);
assert.equal(nativeReturn.action?.destination, 'homeworld:emperor:salusa');
assert.equal(nativeReturn.cost, 2);
assert.equal(homeworldShipmentChoice(view, 'homeworld:emperor', good).action, null);
assert.equal(
homeworldShipmentChoice(view, 'homeworld:atreides', {
'homeworld:guild': { normal: 5, elite: 0 },
@@ -439,7 +439,7 @@ void test('Guild return controls and policy preserve foreign source custody and
busy: false,
}),
);
assert.match(html, /Guild may also return a foreign garrison to Junction/);
assert.match(html, /You may return a foreign garrison to your own Homeworld/);
assert.match(html, /Junction · return to your Homeworld/);
assert.match(html, /Guild pays one spice per two forces, rounded up/);
assert.doesNotMatch(html, /Your own Homeworld cannot/);
+26 -6
View File
@@ -248,14 +248,34 @@ void test('Advanced Emperor may combine its two native origins and retains preci
);
});
void test('own and mutual ally destinations are rejected, including either Emperor world', () => {
void test('Advanced may return an actual foreign garrison to either native Emperor World at the normal rate', () => {
const { context, custody } = fixture();
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa'])
rejected(context, custody, {
...invasion,
destination,
sources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa']) {
const before = structuredClone({ context, custody });
const result = quote(context, custody, {
player: 'e', destination,
sources: { 'homeworld:atreides': { normal: 4, elite: 1 } },
});
assert.equal(result.amount, 5);
assert.equal(result.cost, 5);
assert.equal(result.players[0].reserves, 20);
assert.equal(result.players[0].eliteReserves, 5);
assert.equal(result.state.visitors['homeworld:atreides'], undefined);
const next = { ...context, players: result.players.map(p => ({ ...p, ally: null })) };
assert.deepEqual(totals(next, result.state), totals(context, custody));
assert.deepEqual({ context, custody }, before);
}
});
void test('Basic preserves the native return restriction and all modes reject a mutual ally destination', () => {
const { context, custody } = fixture();
const basic = { ...context, advanced: false };
const basicCustody = { ...custody, salusa: null };
rejected(basic, basicCustody, {
...invasion,
destination: 'homeworld:emperor',
sources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
});
context.players[0].ally = 'a';
context.players[1].ally = 'e';
rejected(context, custody, {
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { takeHarassCard } from './fixture-harass-withdraw';
import {
createReinforcementsHomeworldFixture,
reinforcementsHomeworldPool as pool,
reinforcementsHomeworldPlayer as player,
REINFORCEMENTS_KAITAIN as K,
REINFORCEMENTS_SALUSA as S,
REINFORCEMENTS_CARD,
} from './fixture-reinforcements-homeworld';
/** The original native setup moves one actual ordinary counter to Salusa.
* Its remaining native troops, the real invading force and all cards are
* conserved; choosing the special reserve fee leaves two extra normal
* counters on the native battlefield compared with the legacy fee. */
void test('a named Reinforcements promise finds a necessary special-reserve Homeworld fee', () => {
const f = createReinforcementsHomeworldFixture({
advanced: true, role: 'native', nativeTransfer: 1, slot: 'weapon',
});
const owner = player(f.game, f.owner);
assert.deepEqual(pool(f.game, f.owner, K), { normal: 14, elite: 0 });
assert.deepEqual(pool(f.game, f.owner, S), { normal: 1, elite: 5 });
const support = Math.min(owner.spice, 14);
const wantedDial = (14 + support) / 2;
const legacyMax = (12 + Math.min(owner.spice, 12)) / 2;
assert.ok(wantedDial > legacyMax, 'The legal plan must require preserving normal counters at Kaitain');
const mixed = {
[K]: { normal: 0, elite: 0 },
[S]: { normal: 1, elite: 2 },
};
const manual = { ...f.planActions[0].action, type: 'battlePlan',
weapon: REINFORCEMENTS_CARD, defense: null, dial: wantedDial, support,
reinforcementsSources: mixed, reinforcementsElite: 2 };
assert.doesNotThrow(() => applyAction(f.game, f.owner, manual),
'The actual native plan establishes the chosen physical fee is legal');
let game = structuredClone(f.game);
const truth = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(truth);
takeHarassCard(game, f.observer, truth.id);
game = applyAction(game, f.observer, { type: 'card', card: truth.id });
while (game.truthtrance!.stage === 'priority') {
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
game = applyAction(game, actor, { type: 'truthPass' });
}
game = applyAction(game, f.observer, { type: 'truthAsk', question: {
kind: 'battlePlan', target: f.owner, claim: { kind: 'and', terms: [
{ kind: 'weapon', name: 'Reinforcements' },
{ kind: 'dial', compare: 'eq', value: wantedDial },
] },
} });
assert.ok(viewGame(game, f.owner).truthBattleAnswers!.includes('yes'),
'The real special-reserve plan supports a truthful Yes');
game = applyAction(game, f.owner, { type: 'truthAnswer', answer: 'yes' });
const witness = viewGame(game, f.owner).battle!.compliantPlan; assert.ok(witness);
assert.equal(witness.dial, wantedDial);
assert.ok((witness.reinforcementsElite ?? 0) >= 2);
assert.equal(Object.values(witness.reinforcementsSources!).reduce((sum, group) => sum + group.elite, 0),
witness.reinforcementsElite);
const sealed = applyAction(game, f.owner, { type: 'battlePlan', ...witness });
assert.equal(sealed.battle!.plans[f.owner].dial, wantedDial);
assert.equal(sealed.battle!.plans[f.owner].reinforcementsElite, witness.reinforcementsElite);
});