Files
Dune/tests/advisor-automatic-flip-engine.test.ts
Quintine d0f52c7bae Preserve committed Guild transport through advisor conversion responses
Offer mandatory advisor conversion prevention after Guild cross-shipment and reserve returns. Keep original permission/rate/income, payment, physical pieces and shipment use; bind the exact group to one transport parent and resume original arrival/Intrusion once. Ordinary movement stays unused and the existing provisional continuously-alone duration remains. Preserve old ground receipts, private projection behavior and all transport/module guards.

Validation: 17 files243/243, types/lint/build, eight native transport/response/GameTable SSR journeys and eight existing-profile paths. Genuine36/42-action sources earn original counters/cards and actual paid-shipment Intrusion. Preserve the unaffordable full-price cross outcome and prove the affordable return. No saved-room/D1/restart/deployment operation; broader producers, full modes and browser/deployed acceptance remain open.
2026-10-24 19:58:56 +11:00

114 lines
6.1 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 { fighterCount, isAdvisor } from '../game/advisors';
import { quoteBattleBoard, quoteSpiceCollection } from '../game/board-resolution-quote';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorAutomaticFlipFixture as fixture,
actAdvisorAutomaticFlip as act,
advisorAutomaticFlipPlayer as player,
advisorAutomaticFlipInventory as inventory,
passAdvisorAutomaticFlip as pass,
finishAdvisorAutomaticFlip as finish,
advanceAdvisorAutomaticFlip as advance,
advisorAutomaticFlipClean as clean,
} from './fixture-advisor-automatic-flip';
const cancel = (g: Game, f: ReturnType<typeof fixture>) => act(g, f.holder,
{ type: 'card', card: f.card, mode: 'cancel' });
function rejected(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
for (const kind of ['arrival', 'departure'] as const) {
void test(`native ${kind} commits physical ground movement before its mandatory conversion response`, () => {
const f = fixture({ kind }), before = structuredClone(f.game);
const pending = act(f.game, f.actor, f.action);
assert.deepEqual(f.game, before);
assert.equal(pending.response?.kind, 'advisorFlip');
assert.equal(pending.response?.automaticAdvisorFlip?.territory, f.territory);
const source = pending.response?.automaticAdvisorFlip;
assert.ok(source?.producer === 'groundMovement');
assert.equal(source.mover, f.actor);
assert.equal(player(pending, f.actor).moved, player(before, f.actor).moved + 1);
assert.equal(player(pending, f.actor).forces[f.from], undefined);
assert.equal(player(pending, f.actor).forces[f.to], f.amount);
assert.equal(isAdvisor(player(pending, f.bg), f.territory), true);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.ok(player(pending, f.holder).hand.some(c => c.id === f.card));
const allowed = pass(pending);
assert.equal(isAdvisor(player(allowed, f.bg), f.territory), false);
assert.equal(fighterCount(player(allowed, f.bg), f.territory), 1);
assert.equal(allowed.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(allowed, f.actor).moved, player(pending, f.actor).moved);
assert.deepEqual(inventory(allowed), f.originalInventory);
assert.ok(finish(allowed).phase >= 6);
});
void test(`native ${kind} cancellation preserves advisors and the original committed movement through phase continuation`, () => {
const f = fixture({ kind }), pending = act(f.game, f.actor, f.action);
const stopped = cancel(pending, f), hold = player(stopped, f.bg).advisors?.[f.territory]?.aloneFlipHold;
assert.ok(hold);
assert.equal(stopped.response, null);
assert.equal(stopped.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(stopped, f.actor).moved, 1);
assert.equal(player(stopped, f.actor).forces[f.to], f.amount);
assert.equal(stopped.discard.filter(c => c.id === f.card).length, 1);
for (let read = 0; read < 3; read++) {
const normalized = normalizeAutomaticGame(stopped);
assert.equal(isAdvisor(player(normalized, f.bg), f.territory), true);
const own = viewGame(normalized, f.bg).players.find(p => p.id === f.bg)!;
assert.equal(isAdvisor(own, f.territory), true);
assert.ok(!quoteBattleBoard(normalized).released.some(r => r.player === f.bg && r.territory === f.territory));
const collection = quoteSpiceCollection(normalized).receipts.find(r => r.player === f.bg)!;
assert.equal(collection.strongholds, 0);
assert.equal(collection.collected, 0);
}
const continued = finish(stopped);
assert.ok(continued.phase >= 6);
assert.equal(player(continued, f.bg).advisors?.[f.territory]?.aloneFlipHold, hold);
assert.equal(fighterCount(player(continued, f.bg), f.territory), 0);
assert.deepEqual(inventory(continued), f.originalInventory);
rejected(stopped, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
});
}
void test('an actual outsider return ends the continuously-alone suppression without changing advisor stance', () => {
const f = fixture({ kind: 'arrival' });
let g = cancel(act(f.game, f.actor, f.action), f);
assert.ok(player(g, f.bg).advisors?.[f.territory]?.aloneFlipHold);
g = advance(g, state => state.phase === 5 && state.active === f.holder && clean(state));
g = act(g, f.holder, { ...f.action, amount: 10 });
assert.equal(player(g, f.holder).forces[f.to], 10);
assert.equal(isAdvisor(player(g, f.bg), f.territory), true);
assert.equal(player(g, f.bg).advisors?.[f.territory]?.aloneFlipHold, undefined);
assert.equal(g.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(inventory(g), f.originalInventory);
});
void test('a changed mandatory source rejects before Karama disposal or a second movement', () => {
const f = fixture(), pending = act(f.game, f.actor, f.action);
const changed = structuredClone(pending);
changed.response!.automaticAdvisorFlip = { ...changed.response!.automaticAdvisorFlip!, territory: 'arrakeen' };
rejected(changed, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.ok(player(changed, f.holder).hand.some(c => c.id === f.card));
assert.equal(player(changed, f.actor).moved, 1);
});
void test('four existing profiles can resolve both native mandatory conversions legally', () => {
for (const kind of ['arrival', 'departure'] as const) for (const profile of DIFFICULTIES) {
const f = fixture({ kind }), pending = act(f.game, f.actor, f.action);
const own = viewGame(pending, f.holder);
own.players.find(p => p.id === f.holder)!.bot = profile;
const action = botActions(own)[0]; assert.ok(action);
const settled = pass(act(pending, f.holder, action));
assert.equal(settled.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(settled, f.actor).moved, 1);
assert.deepEqual(inventory(settled), f.originalInventory);
assert.ok(finish(settled).phase >= 6);
}
});