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:
1 parent
0b3413fcd8
commit
9bce22abf5
12 files changed
+901
-29
No files matched your search
@@ -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));
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import {
|
||||
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
|
||||
advisorPreparationPlayer, nextAdvisorPreparationStep,
|
||||
} from './fixture-advisor-preparation';
|
||||
|
||||
export type AdvisorFaceDanceFlipStep = { actor: string; action: Action };
|
||||
export type AdvisorFaceDanceFlipTransition = { before: Game; steps: AdvisorFaceDanceFlipStep[]; after: Game };
|
||||
export const actAdvisorFaceDanceFlip = actAdvisorPreparation;
|
||||
export const advisorFaceDanceFlipClean = advisorPreparationClean;
|
||||
export const advisorFaceDanceFlipInventory = advisorPreparationInventory;
|
||||
export const advisorFaceDanceFlipPlayer = advisorPreparationPlayer;
|
||||
export const advisorFaceDanceFlipTraitors = (game: Game) => {
|
||||
const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
|
||||
[...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader)])];
|
||||
assert.equal(new Set(identities).size, identities.length, 'Each original Traitor/Face Dancer retains one physical slot');
|
||||
return identities.sort();
|
||||
};
|
||||
|
||||
/** Original native continuations never reveal the Face Dancer or pass the new
|
||||
* conversion. Optional preparation and unused special inspection are declined. */
|
||||
export function nextAdvisorFaceDanceFlip(game: Game): AdvisorFaceDanceFlipStep | null {
|
||||
if (game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'faceDance') return null;
|
||||
if (decision?.kind === 'advisorBattle')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'revivalStop' || decision?.kind === 'fullPlanOffer')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'fullPlanRead')
|
||||
return { actor: decision.player, action: { type: 'decision', continue: true } };
|
||||
if (game.status === 'setup' && game.setupStage === 'traitors') {
|
||||
const player = game.players.find(candidate => candidate.traitorChoices.length); assert.ok(player);
|
||||
return { actor: player.id, action: { type: 'traitor', leader: player.traitorChoices[0] } };
|
||||
}
|
||||
}
|
||||
return nextAdvisorPreparationStep(game);
|
||||
}
|
||||
export function advanceAdvisorFaceDanceFlip(game: Game, until: (state: Game) => boolean,
|
||||
steps: AdvisorFaceDanceFlipStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Face Dance chronology ended before its requested boundary');
|
||||
const next = nextAdvisorFaceDanceFlip(game);
|
||||
assert.ok(next, `Choose the original Face Dance/conversion/private plan at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actAdvisorFaceDanceFlip(game, next.actor, next.action, steps);
|
||||
}
|
||||
throw Error('The native advisor Face Dance source did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvisorFaceDanceFlip(game: Game, steps: AdvisorFaceDanceFlipStep[] = []): Game {
|
||||
for (let count = 0; game.response && !game.response.automaticAdvisorFlip && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actAdvisorFaceDanceFlip(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
|
||||
}
|
||||
|
||||
/** Select only the first native Face Dancer shuffle in the actual final setup
|
||||
* action. No held card, dealt identity or revealed status is ever assigned. */
|
||||
function dealOriginalFaceDancers(game: Game, step: AdvisorFaceDanceFlipStep,
|
||||
steps: AdvisorFaceDanceFlipStep[]) {
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.setupStage, 'forces');
|
||||
assert.equal(step.action.type, 'advisorSetup');
|
||||
assert.ok(game.players.every(player => !player.traitorChoices.length && !player.faceDancers));
|
||||
const held = new Set(game.players.flatMap(player => player.traitors));
|
||||
const original = traitorDeck(game.players, true).filter(identity => !held.has(identity));
|
||||
const first = ['tleilaxu-3', 'tleilaxu-4', 'atreides-0'];
|
||||
assert.ok(first.every(identity => original.includes(identity)));
|
||||
const order = [...first, ...original.filter(identity => !first.includes(identity))];
|
||||
const current = [...original], scalars: number[] = [];
|
||||
for (let index = current.length - 1; index > 0; index--) {
|
||||
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
|
||||
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
|
||||
[current[index], current[from]] = [current[from], current[index]];
|
||||
}
|
||||
assert.deepEqual(current, order);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) {
|
||||
assert.ok(cursor < scalars.length, 'Only the first actual Face Dancer shuffle uses selected scalar entropy');
|
||||
array[0] = scalars[cursor++];
|
||||
} else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const next = applyAction(game, step.actor, step.action);
|
||||
assert.equal(cursor, scalars.length, 'The original Face Dancer shuffle consumes its selected permutation once');
|
||||
steps.push(structuredClone(step)); return { game: next, original, order, scalars };
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Public Advanced BG/Tleilaxu/Atreides, in that original seat order. Native
|
||||
* setup/deals, paid shipment and ground movement earn one real battle and its
|
||||
* private Face Dance opportunity. Stop BEFORE the original reveal action. */
|
||||
export function createAdvisorFaceDanceFlipFixture(options: { zeroReplacement?: boolean } = {}) {
|
||||
const zeroReplacement = options.zeroReplacement ?? false;
|
||||
const bg = 'beneGesserit', tleilaxu = 'tleilaxu', holder = 'atreides';
|
||||
const territory = 'arrakeen', location = 'arrakeen:10', battleTerritory = zeroReplacement ? territory : 'imperial_basin';
|
||||
let game = createGame('ADVISORFACEDANCEFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true, ['ix']);
|
||||
joinGame(game, newPlayer(tleilaxu, 'Tleilaxu', 'tleilaxu'));
|
||||
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
|
||||
const initial = structuredClone(game), steps: AdvisorFaceDanceFlipStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorFaceDanceFlip(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorFaceDanceFlip(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorFaceDanceFlipTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.players.map(player => player.faction), [bg, tleilaxu, holder]);
|
||||
assert.deepEqual(game.expansions, ['ix']);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length && !player.faceDancers));
|
||||
const originalInventory = advisorFaceDanceFlipInventory(game); assert.equal(originalInventory.length, 47);
|
||||
const originalTraitors = traitorDeck(game.players, true).sort();
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
assert.equal(ordinary.length, 2);
|
||||
const selected = [ordinary[0], ordinary[1], karama];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: AdvisorFaceDanceFlipTransition | null = null;
|
||||
let faceDancerDeal: ReturnType<typeof dealOriginalFaceDancers> | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorFaceDanceFlip(game); assert.ok(next);
|
||||
if (next.action.type === 'advisorSetup') {
|
||||
const before = structuredClone(game), start = steps.length;
|
||||
faceDancerDeal = dealOriginalFaceDancers(game, next, steps); game = faceDancerDeal.game;
|
||||
advisorSetup = { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
} else act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup && faceDancerDeal);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(advisorFaceDanceFlipPlayer(game, bg).forces[location], 1);
|
||||
assert.ok(advisorFaceDanceFlipPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorFaceDanceFlipPlayer(game, holder).forces[location], 10);
|
||||
const dancers = structuredClone(advisorFaceDanceFlipPlayer(game, tleilaxu).faceDancers!);
|
||||
assert.equal(dancers.length, 3); assert.ok(dancers.every(card => !card.revealed));
|
||||
const winner = holder;
|
||||
const winningDisc = advisorFaceDanceFlipPlayer(game, winner).leaders
|
||||
.filter(leader => !leader.dead && dancers.some(card => !card.revealed && card.leader === leader.id))
|
||||
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0]; assert.ok(winningDisc);
|
||||
const losingDisc = advisorFaceDanceFlipPlayer(game, tleilaxu).leaders
|
||||
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0]; assert.ok(losingDisc);
|
||||
const winnerLeader = winningDisc.id, loserLeader = losingDisc.id;
|
||||
assert.deepEqual(advisorFaceDanceFlipTraitors(game), originalTraitors);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && !state.phaseOpening && !state.response && state.decision?.kind === 'advisorBattle');
|
||||
const openingOffer = structuredClone(game);
|
||||
const declinedPreparation = transition(() => {
|
||||
act(bg, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && state.active === bg && advisorFaceDanceFlipClean(state));
|
||||
});
|
||||
act(bg, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === tleilaxu && advisorFaceDanceFlipClean(state));
|
||||
const shipment = transition(() => {
|
||||
act(tleilaxu, { type: 'ship', territory, sector: 10, amount: 3, elite: 0, allyPayment: 0 });
|
||||
reach(advisorFaceDanceFlipClean);
|
||||
});
|
||||
assert.equal(advisorFaceDanceFlipPlayer(shipment.before, tleilaxu).spice - advisorFaceDanceFlipPlayer(game, tleilaxu).spice, 3);
|
||||
const tleilaxuMovement = transition(() => {
|
||||
act(tleilaxu, { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
reach(advisorFaceDanceFlipClean);
|
||||
});
|
||||
assert.equal(advisorFaceDanceFlipPlayer(game, tleilaxu).forces[location], 1);
|
||||
assert.equal(advisorFaceDanceFlipPlayer(game, tleilaxu).forces['imperial_basin:10'], 2);
|
||||
act(tleilaxu, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === holder && advisorFaceDanceFlipClean(state));
|
||||
const atreidesDeparture = !zeroReplacement ? transition(() => {
|
||||
act(holder, { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 10 });
|
||||
reach(advisorFaceDanceFlipClean);
|
||||
}) : null;
|
||||
reach(state => state.phase === 6 && advisorFaceDanceFlipClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = game.players.flatMap(player => viewGame(game, player.id).battleChoices)
|
||||
.find(candidate => candidate.territory === battleTerritory); assert.ok(choice);
|
||||
const battleStep: AdvisorFaceDanceFlipStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: battleTerritory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
|
||||
} };
|
||||
act(battleStep.actor, battleStep.action);
|
||||
const beforePreparation = structuredClone(game);
|
||||
reach(state => !!state.battle && advisorFaceDanceFlipClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||
const beforePlans = structuredClone(game);
|
||||
const planSteps: AdvisorFaceDanceFlipStep[] = [
|
||||
{ actor: winner, action: { type: 'battlePlan', leader: winnerLeader, dial: 1, support: 1, weapon: null, defense: null } },
|
||||
{ actor: tleilaxu, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const step of planSteps) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'faceDance');
|
||||
assert.equal(game.decision?.player, tleilaxu);
|
||||
assert.ok(game.decision?.kind === 'faceDance');
|
||||
assert.equal(game.decision.winner, winner); assert.equal(game.decision.identity, winnerLeader);
|
||||
const survivors = Object.entries(advisorFaceDanceFlipPlayer(game, winner).forces)
|
||||
.filter(([key]) => key.startsWith(`${battleTerritory}:`)).reduce((sum, [, amount]) => sum + amount, 0);
|
||||
assert.equal(survivors, 9);
|
||||
assert.equal(advisorFaceDanceFlipPlayer(game, bg).forces[location], 1);
|
||||
assert.ok(advisorFaceDanceFlipPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorFaceDanceFlipPlayer(game, tleilaxu).forces[location] ?? 0, zeroReplacement ? 0 : 1);
|
||||
assert.ok(advisorFaceDanceFlipPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorFaceDanceFlipInventory(game), originalInventory);
|
||||
assert.deepEqual(advisorFaceDanceFlipTraitors(game), originalTraitors);
|
||||
const sources: Record<string, number> = zeroReplacement ? {} : { [location]: 1, reserves: survivors - 1 };
|
||||
const action: Action = { type: 'decision', reveal: true, sources, sector: 10 };
|
||||
const winnerBefore = structuredClone(advisorFaceDanceFlipPlayer(game, winner));
|
||||
const dancerBefore = structuredClone(advisorFaceDanceFlipPlayer(game, tleilaxu).faceDancers!.find(card => card.leader === winnerLeader)!);
|
||||
return { game, source: structuredClone(game), beforeFaceDance: structuredClone(game), initial, lobby, setup,
|
||||
beforeStartingDeal, afterSetup, advisorSetup, faceDancerDeal: {
|
||||
before: advisorSetup.before, after: advisorSetup.after, steps: advisorSetup.steps,
|
||||
original: faceDancerDeal.original, order: faceDancerDeal.order, scalars: faceDancerDeal.scalars,
|
||||
}, dancers, openingOffer, declinedPreparation, shipment, tleilaxuMovement, atreidesDeparture,
|
||||
beforeBattle, battleStep, beforePreparation, beforePlans, planSteps, revealed,
|
||||
zeroReplacement, bg, owner: bg, tleilaxu, actor: tleilaxu, holder, atreides: holder,
|
||||
karama: karama.id, card: karama.id, winner, winnerLeader, loserLeader, battleTerritory,
|
||||
territory, location, from: location, sector: 10, sources, survivors, amount: zeroReplacement ? 0 : survivors,
|
||||
action, faceDanceStep: { actor: tleilaxu, action } satisfies AdvisorFaceDanceFlipStep,
|
||||
winnerBefore, winnerReservesBefore: winnerBefore.reserves,
|
||||
winnerLeaderBefore: structuredClone(winnerBefore.leaders.find(leader => leader.id === winnerLeader)!), dancerBefore,
|
||||
context: structuredClone(game.lastBattleContext),
|
||||
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalTraitors, originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public Advanced BG/Tleilaxu/Atreides setup in that seat order, original Ix47 deck and no optional module. Native prediction and actual first-card Traitor choices precede actual advisorSetup placing the original BG advisor beside Atreides’ native ten Arrakeen10 counters.',
|
||||
'Select only original scalar shuffle draws, three original unopened Treachery positions and two original unopened ordinary Spice positions. BG and Tleilaxu receive ordinary cards; Atreides receives its original Karama. No played hand, army, phase, wallet or Tanks is installed.',
|
||||
'Only the original finishSetup Face Dancer shuffle inside actual advisorSetup uses a selected conserved permutation of the original available Traitor identities: Tleilaxu3, Tleilaxu4, Atreides0 first. The real native deal creates all three unrevealed cards after other factions choose Traitors; no dealt card or status is installed. Choose the strongest matching living native Atreides disc from that earned original hand.',
|
||||
'Actually complete safe first Storm, both Spice blows, Charity, Bidding and Revival. BG declines preparation and ends movement. Tleilaxu pays three to ship three reserves into Arrakeen10, then actually moves two to Basin10, leaving one beside BG and Atreides.',
|
||||
zeroReplacement
|
||||
? 'Atreides actually ends its unused movement without departing. The actual Arrakeen A10/T1 battle leaves nine winning Atreides counters beside BG after ordinary dial/support1 casualties. Zero replacement returns those native survivors without bringing a new Tleilaxu counter.'
|
||||
: 'Atreides actually moves all ten native Arrakeen counters to Basin10 and fights the actual Tleilaxu two there. A real unarmed native-leader win with dial/support1 leaves nine Atreides survivors; the returned Face Dance action selects one actual remote Arrakeen counter and eight actual reserves.',
|
||||
'Native battle preparation, optional special-inspection decline, actual plans, declined Traitor calls and original winner cleanup reach the original private Face Dance chooser. The original Karama remains held. Stop before revealing; leader death, winning-force return, source withdrawal, revealed identity and mandatory advisor conversion are caller-owned actual outcomes.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type AdvisorFaceDanceFlipFixture = ReturnType<typeof createAdvisorFaceDanceFlipFixture>;
|
||||
|
||||
/** Submit only the original reveal action. Any new mandatory conversion stays
|
||||
* caller-owned after the committed return, death, reveal and replacement. */
|
||||
export function commitAdvisorFaceDanceFlip(source: Game, fixture: AdvisorFaceDanceFlipFixture,
|
||||
steps: AdvisorFaceDanceFlipStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
const game = actAdvisorFaceDanceFlip(source, fixture.tleilaxu, fixture.action, steps);
|
||||
const winnerBefore = advisorFaceDanceFlipPlayer(before, fixture.winner);
|
||||
const winnerAfter = advisorFaceDanceFlipPlayer(game, fixture.winner);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)),
|
||||
winnerReservesBefore: winnerBefore.reserves, winnerReservesAfter: winnerAfter.reserves,
|
||||
winnerLeaderBefore: structuredClone(winnerBefore.leaders.find(leader => leader.id === fixture.winnerLeader)!),
|
||||
winnerLeaderAfter: structuredClone(winnerAfter.leaders.find(leader => leader.id === fixture.winnerLeader)!),
|
||||
dancerBefore: structuredClone(advisorFaceDanceFlipPlayer(before, fixture.tleilaxu).faceDancers!.find(card => card.leader === fixture.winnerLeader)!),
|
||||
dancerAfter: structuredClone(advisorFaceDanceFlipPlayer(game, fixture.tleilaxu).faceDancers!.find(card => card.leader === fixture.winnerLeader)!),
|
||||
};
|
||||
}
|
||||
/** Call after the explicit conversion choice, then use original phase controls. */
|
||||
export function finishAdvisorFaceDanceFlip(source: Game, steps: AdvisorFaceDanceFlipStep[] = []): Game {
|
||||
return advanceAdvisorFaceDanceFlip(source,
|
||||
game => !game.battle && game.phase >= 7 && game.phase <= 8 && advisorFaceDanceFlipClean(game), steps);
|
||||
}
|
||||
Reference in new issue
Block a user