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.
This commit is contained in:
Quintine committed 2026-10-24 21:00:58 +11:00
1 parent 0b3413fcd8
commit 9bce22abf5
12 files changed
+901 -29

No files matched your search

+199
View File
@@ -12,6 +12,7 @@ import {
type AutomaticAdvisorGamont,
type AutomaticAdvisorBattle,
type AutomaticAdvisorHomeworldTransport,
type AutomaticAdvisorFaceDance,
} from '../game/advisor-automatic-flip';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
@@ -20,6 +21,7 @@ type TransportSource = Extract<AutomaticAdvisorFlipSource, { producer: 'guildTra
type GamontSource = Extract<AutomaticAdvisorFlipSource, { producer: 'gamont' }>;
type BattleSource = Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>;
type HomeworldSource = Extract<AutomaticAdvisorFlipSource, { producer: 'homeworldTransport' }>;
type FaceDanceSource = Extract<AutomaticAdvisorFlipSource, { producer: 'faceDance' }>;
function context(): AutomaticAdvisorFlipContext {
return {
@@ -806,3 +808,200 @@ void test('Homeworld source validation never reads original shipment events, pri
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
}
});
function faceDance(combatant = true, result: 'normal' | 'traitor' = 'normal') {
const { ctx, source: ground } = held();
ctx.phase = 6;
ctx.battle = null;
ctx.players[0].forces = { 'imperial_basin:9': 2, 'imperial_basin:10': 1, 'arrakeen:10': 1 };
ctx.players[1].forces = {};
ctx.players[2].faction = 'tleilaxu';
ctx.players[2].forces = { 'carthag:11': 2 };
const battle: AutomaticAdvisorBattle = {
event: 'original-face-dance-battle', turn: ctx.turn, territory: 'carthag',
combatants: combatant ? ['b', 'a'] : ['a', 'r'], winner: 'a', result,
};
const receipt: AutomaticAdvisorFaceDance = {
event: 'completed-public-face-dance', battle, player: 'r', territory: 'carthag', sector: 11,
sources: { 'imperial_basin:9': 1, reserves: 1 }, amount: 2, returned: { a: 2 },
};
ctx.lastBattleContext = { ...battle, combatants: [...battle.combatants] };
const source: FaceDanceSource = {
event: 'automatic-after-remote-face-dance-source', owner: ground.owner,
territory: ground.territory, turn: ground.turn, phase: 6,
forces: ground.forces, producer: 'faceDance', faceDance: receipt,
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
};
return { ctx, source };
}
void test('Face Dance remote sources accept a BG original combatant or bystander after a normal or traitor result', () => {
for (const combatant of [false, true]) {
for (const result of ['normal', 'traitor'] as const) {
const original = faceDance(combatant, result);
const before = structuredClone(original);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
assert.deepEqual(original, before);
assert.deepEqual(Object.keys(original.source).sort(), ['event', 'faceDance', 'forces', 'owner', 'phase', 'producer', 'territory', 'turn']);
assert.deepEqual(Object.keys(original.source.faceDance).sort(), ['amount', 'battle', 'event', 'player', 'returned', 'sector', 'sources', 'territory']);
}
}
});
void test('every positive board source can bind its own exact held group and returned Ecaz co-side counts may include Tleilaxu', () => {
const { ctx, source } = faceDance();
source.faceDance.sources = { 'imperial_basin:9': 1, 'hagga_basin:12': 1 };
ctx.players[0].forces = { ...ctx.players[0].forces, 'hagga_basin:12': 1 };
const remote: FaceDanceSource = {
...source, event: 'automatic-after-second-remote-source', territory: 'hagga_basin',
forces: { 'hagga_basin:12': 1 },
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
hagga_basin: { aloneFlipHold: remote.event },
};
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, remote));
ctx.players[1].faction = 'ecaz';
source.faceDance.returned = { a: 1, r: 1 };
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, remote));
});
void test('zero replacement can leave advisors at the board target and a native World target delegates its new physical deposit', () => {
const zero = faceDance(false);
zero.source.territory = 'carthag';
zero.source.forces = { 'carthag:11': 2 };
zero.source.faceDance.amount = 0;
zero.source.faceDance.sources = {};
zero.ctx.players[0].forces = { 'carthag:11': 2 };
zero.ctx.players[0].advisors = { carthag: { aloneFlipHold: zero.source.event } };
zero.ctx.players[2].forces = {};
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(zero.ctx, zero.source));
const world = faceDance();
world.source.faceDance.territory = 'homeworld:tleilaxu';
world.source.faceDance.sector = 0;
world.source.faceDance.amount = 1;
world.source.faceDance.sources = { 'imperial_basin:9': 1 };
world.source.faceDance.battle.territory = 'homeworld:tleilaxu';
world.ctx.lastBattleContext = {
...world.source.faceDance.battle, combatants: [...world.source.faceDance.battle.combatants],
};
world.ctx.players[2].forces = {};
assert.throws(() => validateAutomaticAdvisorFlip(world.ctx, world.source), AutomaticAdvisorFlipError);
world.ctx.homeworlds = { custody: {} };
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(world.ctx, world.source));
world.source.faceDance.sector = 1;
assert.throws(() => validateAutomaticAdvisorFlip(world.ctx, world.source), AutomaticAdvisorFlipError);
});
void test('Face Dance validates current public battle identity, actor, positive sources, returns, board deposit and held group', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: FaceDanceSource) => void)[] = [
(ctx) => { ctx.battle = {}; },
(ctx) => { ctx.lastBattleContext = null; },
(ctx) => { ctx.lastBattleContext!.event = 'stale'; },
(ctx) => { ctx.lastBattleContext!.combatants = ['a', 'b']; },
(_ctx, source) => { source.faceDance.event = ''; },
(_ctx, source) => { source.faceDance.battle.event = 'stale'; },
(_ctx, source) => { source.faceDance.battle.turn++; },
(_ctx, source) => { source.faceDance.battle.winner = null; },
(ctx, source) => {
source.faceDance.battle.combatants = ['b', 'r'];
source.faceDance.battle.winner = 'r';
ctx.lastBattleContext = { ...source.faceDance.battle, combatants: ['b', 'r'] };
},
(_ctx, source) => { source.faceDance.player = 'missing'; },
(ctx) => { ctx.players[2].faction = 'richese'; },
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[2] }]; },
(_ctx, source) => { source.faceDance.territory = 'arrakeen'; },
(_ctx, source) => { source.faceDance.sector = 10; },
(_ctx, source) => { source.faceDance.amount = -1; },
(_ctx, source) => { source.faceDance.amount = 3; },
(_ctx, source) => { source.faceDance.sources = { reserves: 2 }; },
(_ctx, source) => { source.faceDance.sources = { 'imperial_basin:8': 2 }; },
(_ctx, source) => { source.faceDance.sources = { 'imperial_basin:9.0': 2 }; },
(_ctx, source) => { source.faceDance.sources = { 'homeworld:tleilaxu:0': 2 }; },
(_ctx, source) => { source.faceDance.sources = { 'imperial_basin:9': 1, reserves: 1, 'hagga_basin:12': 0 }; },
(_ctx, source) => { source.faceDance.sources = { 'imperial_basin:9': 1.5, reserves: 0.5 }; },
(_ctx, source) => { source.faceDance.returned = { missing: 2 }; },
(_ctx, source) => { source.faceDance.returned = { a: 0 }; },
(_ctx, source) => { source.faceDance.returned = { a: 1.5 }; },
(ctx) => { ctx.players[2].forces = { 'carthag:11': 1 }; },
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
];
for (const change of changes) {
const original = faceDance();
change(original.ctx, original.source);
const before = structuredClone(original);
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
assert.deepEqual(original, before);
}
for (const result of ['mutualTraitors', 'explosion'] as const) {
const original = faceDance();
original.source.faceDance.battle.result = result;
original.source.faceDance.battle.winner = null;
original.ctx.lastBattleContext = {
...original.source.faceDance.battle, combatants: [...original.source.faceDance.battle.combatants],
};
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
}
});
void test('Face Dance public layers reject private identities, cards, skills and continuation fields', () => {
const { ctx, source } = faceDance();
for (const field of ['leader', 'faceDancer', 'card', 'skills', 'continuation']) {
for (const malformed of [
{ ...source, [field]: 'private-payload' },
{ ...source, faceDance: { ...source.faceDance, [field]: 'private-payload' } },
{ ...source, faceDance: { ...source.faceDance, battle: { ...source.faceDance.battle, [field]: 'private-payload' } } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
}
for (const malformed of [
{ ...source, faceDance: null },
{ ...source, faceDance: { ...source.faceDance, sources: null } },
{ ...source, faceDance: { ...source.faceDance, returned: [] } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('Face Dance remains phase-six-only and does not alter existing producer JSON shapes or phase windows', () => {
for (const original of [held(), transported(), gamont(false), battleResult(), homeworldTransport()]) {
const before = JSON.stringify(original.source);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
assert.equal(JSON.stringify(original.source), before);
}
for (const phase of [5, 8]) {
const { ctx, source } = faceDance();
ctx.phase = phase;
source.phase = phase;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
}
});
void test('completed Face Dance validation never rereads availability, current dancers or private parent continuation', () => {
const { ctx, source } = faceDance();
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'reserves', 'moved', 'shipped']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Face Dance helper read pretransfer or private ${player.id} ${field}`); },
});
}
}
for (const field of ['pendingFaceDance', 'pendingAutomaticAdvisorFaceDance', 'leaderSkills', 'continuation']) {
Object.defineProperty(ctx, field, {
get() { throw new Error(`Face Dance helper read private parent ${field}`); },
});
}
for (const field of ['cardRoles', 'mandatoryDiscard', 'winnerDiscards', 'casualtyChoices', 'continuation']) {
Object.defineProperty(ctx.lastBattleContext, field, {
get() { throw new Error(`Face Dance helper read private battle ${field}`); },
});
}
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
});