Files
Dune/tests/advisor-automatic-flip-engine.test.ts
T
Quintine 3aca64412f Offer Karama against mandatory advisor ground conversions
Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open.

Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
2026-10-24 19:44:12 +11:00

112 lines
6.0 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);
assert.equal(pending.response?.automaticAdvisorFlip?.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);
}
});