Files
Dune/tests/advisor-homeworld-flip-engine.test.ts
T
Quintine 0b3413fcd8 Preserve Homeworld and Junction transfers through advisor conversion
Capture surviving alone BG groups after committed native board-to-world and
Junction board transfers. Keep typed payment/custody and shipment use, private
original declaration snapshots and one original Junction arrival. Preserve
Junction immunity independently of the BG conversion counter.

Validation: 18 affected files218/218, types/lint/build, nine native transport
and GameTable SSR journeys, sixteen existing-profile response paths. Genuine
37/38/42/36-action sources earn original routes and costs3/5/10/1. Rename two
fixture variables rejected by lint without changing rule validation. Broader
combinations/full modes remain unfinished. Not deployed; no saved-room/D1,
reset or restart operation. Runtime fingerprint
5365a5d6fba829c6bcee1cb1e00977ec41769912a32180062e52af0ca59645da;
checkpoint prose/reference overlays are reviewed separately.
2026-10-24 20:49:40 +11:00

122 lines
7.2 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { isAdvisor, fighterCount } from '../game/advisors';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorHomeworldFlipFixture as fixture, actAdvisorHomeworldFlip as act,
commitAdvisorHomeworldFlip as commit, finishAdvisorHomeworldFlip as finish,
advisorHomeworldFlipPlayer as player, advisorHomeworldFlipInventory as inventory,
} from './fixture-advisor-homeworld-flip';
const cases = [
{ kind: 'native' as const },
{ kind: 'junction' as const },
{ kind: 'junction' as const, intrusion: true },
{ kind: 'junction' as const, advisorArrival: true },
];
function allow(g: Game) {
for (let count = 0; g.response?.automaticAdvisorFlip && count < 30; count++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor);
g = act(g, actor.id, { type: 'passResponse' });
}
assert.ok(!g.response?.automaticAdvisorFlip); return g;
}
function rejected(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
}
const physical = (g: Game) => ({ custody: g.homeworlds!.custody,
players: g.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
spice: p.spice, shipped: p.shipped, moved: p.moved })) });
for (const config of cases) for (const canceled of [false, true]) {
void test(`${config.kind}${config.intrusion ? ' Intrusion' : config.advisorArrival ? ' advisor arrival' : ' return'} ${canceled ? 'prevention' : 'allowance'} keeps paid transport and resumes only its own arrival`, () => {
const f = fixture(config), before = structuredClone(f.game), pending = commit(f.game, f).game;
assert.deepEqual(f.game, before);
const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'homeworldTransport');
assert.equal(source.transport.kind, config.kind); assert.equal(source.transport.destination, f.to);
assert.equal(pending.decision, null);
assert.equal(player(pending, f.shipper).spice, player(before, f.shipper).spice - f.cost);
assert.equal(player(pending, f.shipper).forces[f.from], undefined);
assert.equal(player(pending, f.shipper).shipped, true); assert.equal(player(pending, f.shipper).moved, 0);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama));
const parent = pending.pendingAutomaticAdvisorHomeworldTransport!;
assert.ok(parent.sourceEvent.length > 0 && parent.sourceEvent !== source.transport.event);
const publicResponse = JSON.stringify(viewGame(pending, f.holder).response);
assert.ok(!publicResponse.includes('sourceEvent') && !publicResponse.includes('signature'));
assert.ok(!publicResponse.includes(parent.sourceEvent));
if (f.to.startsWith('homeworld:')) {
const deposit = homeworldForceGroups(homeworldContext(pending), pending.homeworlds!.custody!)
.find(world => world.id === f.to)!.forces[f.shipper];
assert.equal(deposit.normal + deposit.elite, 20);
assert.equal(parent.arrival, undefined);
} else assert.equal(player(pending, f.shipper).forces[f.to], f.amount);
if (config.intrusion) rejected(pending, f.bg, { type: 'decision', accept: false });
const committed = physical(pending);
let done = canceled
? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }) : allow(pending);
assert.equal(done.pendingAutomaticAdvisorHomeworldTransport, undefined);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(physical(done), committed);
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
if (config.intrusion) {
assert.equal(done.decision?.kind, 'intrusion');
assert.equal(done.decision?.player, f.bg);
done = act(done, f.bg, { type: 'decision', accept: false });
assert.equal(done.decision, null);
} else assert.equal(done.decision, null);
for (let read = 0; read < 3; read++) {
done = normalizeAutomaticGame(done); assert.equal(done.decision, null);
assert.deepEqual(physical(done), committed);
}
const finished = finish(done);
assert.ok(finished.phase === 6 || finished.phase === 7);
assert.deepEqual(inventory(finished), f.originalInventory);
assert.equal(finished.discard.filter(c => c.id === f.karama).length, canceled ? 1 : 0);
assert.equal(player(finished, f.shipper).forces[f.from], undefined);
assert.equal(player(finished, f.shipper).tanks, player(before, f.shipper).tanks);
});
}
void test('earlier native transport privilege prevention leaves Homeworld payment and the last outsider untouched', () => {
const f = fixture(); let declared = act(f.game, f.shipper, f.action);
if (declared.decision?.kind === 'homeworldShipmentGuild') declared = act(declared,
declared.decision.player, { type: 'decision', event: declared.decision.event, allow: true });
assert.equal(declared.response?.kind, 'guildTransportPermission');
const done = act(declared, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(done.pendingAutomaticAdvisorHomeworldTransport, undefined);
assert.equal(player(done, f.shipper).forces[f.from], f.amount);
assert.equal(player(done, f.shipper).spice, player(f.game, f.shipper).spice);
assert.equal(player(done, f.shipper).shipped, false);
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
});
void test('changed private source or arrival rejects before a conversion counter spends its card', () => {
const f = fixture({ kind: 'junction', intrusion: true }), pending = commit(f.game, f).game;
for (const field of ['source', 'arrival'] as const) {
const invalid = structuredClone(pending);
if (field === 'source') invalid.pendingAutomaticAdvisorHomeworldTransport!.sourceEvent = 'different-original-source';
else invalid.pendingAutomaticAdvisorHomeworldTransport!.arrival!.sector = 9;
rejected(invalid, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.deepEqual(physical(invalid), physical(pending));
assert.ok(player(invalid, f.holder).hand.some(c => c.id === f.karama));
}
});
void test('all four existing profiles resolve each Homeworld transport conversion legally', () => {
for (const config of cases) for (const profile of DIFFICULTIES) {
const f = fixture(config), pending = commit(f.game, f).game, own = viewGame(pending, f.holder);
own.players.find(p => p.id === f.holder)!.bot = profile;
const actions = botActions(own); assert.ok(actions.length);
let done = allow(act(pending, f.holder, actions[0]));
if (done.decision?.kind === 'intrusion') done = act(done, f.bg, { type: 'decision', accept: false });
assert.equal(done.pendingAutomaticAdvisorHomeworldTransport, undefined);
assert.deepEqual(physical(done), physical(pending));
assert.deepEqual(inventory(done), f.originalInventory);
}
});