Files
Dune/tests/advisor-face-dance-flip-engine.test.ts
Quintine 9bce22abf5 Resolve advisor conversion after complete Face Dance replacement
Capture surviving alone BG groups at positive remote board sources and zero
replacement targets. Preserve returned armies, replacement, original leader
death, revelation and card cycle. Native Face Dance stays immune; consume the
publicly sourced parent before one original aftermath continuation.

Validation: 16 affected files201/201, types/lint/build, five native Face Dance
and GameTable SSR journeys, eight existing-profile response paths. Genuine
67/66-action sources earn original Ix47, native Face Dancers and actual battle.
Both conversion outcomes preserve nine returned survivors and one leader death;
actual BG city income is2/0. Correct a fixture's expected deck count43 to47
without changing cards or rules. Full combinations/modes remain unfinished.
Not deployed; no saved-room/D1, reset or restart operation. Runtime fingerprint
78239a9def5dd70ae404d5a614a21ea09ea615dd2a6cde7d05760715416d7401;
checkpoint prose/reference overlays are reviewed separately.
2026-10-24 21:00:58 +11:00

108 lines
6.6 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 { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorFaceDanceFlipFixture as fixture, actAdvisorFaceDanceFlip as act,
commitAdvisorFaceDanceFlip as commit, finishAdvisorFaceDanceFlip as finish,
advisorFaceDanceFlipPlayer as player, advisorFaceDanceFlipInventory as inventory,
advisorFaceDanceFlipTraitors as traitors,
} from './fixture-advisor-face-dance-flip';
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) => g.players.map(p => ({ id: p.id, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses, leaders: p.leaders }));
for (const zeroReplacement of [false, true]) for (const canceled of [false, true]) {
void test(`native ${zeroReplacement ? 'zero replacement' : 'remote source'} ${canceled ? 'prevention' : 'allowance'} preserves completed Face Dance and resumes aftermath once`, () => {
const f = fixture({ zeroReplacement }), before = structuredClone(f.game);
const pending = commit(f.game, f).game;
assert.deepEqual(f.game, before);
assert.equal(pending.phase, 6); assert.equal(pending.battle, null);
assert.equal(pending.decision, null); assert.equal(pending.pendingFaceDance, null);
const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'faceDance');
assert.equal(source.faceDance.battle.event, pending.lastBattleContext?.event);
assert.equal(source.faceDance.territory, f.battleTerritory);
assert.equal(source.faceDance.amount, zeroReplacement ? 0 : 9);
assert.deepEqual(source.faceDance.sources, f.sources);
assert.deepEqual(source.faceDance.returned, { [f.winner]: 9 });
assert.equal(player(pending, f.winner).reserves, player(before, f.winner).reserves + 9);
assert.equal(player(pending, f.winner).forces[`${f.battleTerritory}:10`], undefined);
const leader = player(pending, f.winner).leaders.find(l => l.id === f.winnerLeader)!;
assert.equal(leader.dead, true);
assert.equal(leader.deaths, player(before, f.winner).leaders.find(l => l.id === f.winnerLeader)!.deaths + 1);
assert.equal(player(pending, f.tleilaxu).faceDancers!.find(c => c.leader === f.winnerLeader)!.revealed, true);
assert.equal(player(pending, f.tleilaxu).forces[f.location], undefined);
assert.equal(player(pending, f.tleilaxu).forces['imperial_basin:10'], zeroReplacement ? 2 : 9);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.deepEqual(pending.players.map(p => p.spice), before.players.map(p => p.spice), 'Face Dance grants no extra bounty');
assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama));
assert.ok(!JSON.stringify(viewGame(pending, f.holder).response).includes(f.winnerLeader));
rejected(pending, f.tleilaxu, f.action);
const committed = physical(pending), bgSpice = player(pending, f.bg).spice;
let done = canceled
? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }) : allow(pending);
assert.equal(done.pendingAutomaticAdvisorFaceDance, undefined);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(physical(done), committed);
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
done = finish(done);
assert.equal(done.phase, 7);
assert.equal(player(done, f.bg).spice, bgSpice + (canceled ? 0 : 2));
assert.deepEqual(physical(done), committed);
for (let read = 0; read < 3; read++) {
done = normalizeAutomaticGame(done);
assert.deepEqual(physical(done), committed);
assert.equal(player(done, f.bg).spice, bgSpice + (canceled ? 0 : 2));
}
assert.equal(done.discard.filter(c => c.id === f.karama).length, canceled ? 1 : 0);
assert.deepEqual(inventory(done), f.originalInventory);
assert.deepEqual(traitors(done), f.originalTraitors);
});
}
void test('declining native Face Dance creates no conversion and keeps the original winner and unrevealed card', () => {
const f = fixture();
rejected(f.game, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
const declined = act(f.game, f.tleilaxu, { type: 'decision', reveal: false });
assert.equal(declined.pendingAutomaticAdvisorFaceDance, undefined);
assert.equal(player(declined, f.winner).forces[`${f.battleTerritory}:10`], 9);
assert.equal(player(declined, f.winner).leaders.find(l => l.id === f.winnerLeader)!.dead, false);
assert.equal(player(declined, f.tleilaxu).faceDancers!.find(c => c.leader === f.winnerLeader)!.revealed, false);
assert.equal(player(declined, f.tleilaxu).forces[f.location], 1);
const done = finish(declined);
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('a changed completed Face Dance parent rejects before another card, force or leader is consumed', () => {
const f = fixture(), pending = commit(f.game, f).game;
pending.pendingAutomaticAdvisorFaceDance = { ...pending.pendingAutomaticAdvisorFaceDance!, amount: 8 };
rejected(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.ok(player(pending, f.holder).hand.some(c => c.id === f.karama));
assert.equal(player(pending, f.winner).leaders.find(l => l.id === f.winnerLeader)!.deaths, 1);
});
void test('all four existing profiles handle both Face Dance conversion causes legally', () => {
for (const zeroReplacement of [false, true]) for (const profile of DIFFICULTIES) {
const f = fixture({ zeroReplacement }), 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);
const done = finish(allow(act(pending, f.holder, actions[0])));
assert.equal(done.pendingAutomaticAdvisorFaceDance, undefined);
assert.deepEqual(physical(done), physical(pending));
assert.deepEqual(inventory(done), f.originalInventory);
}
});