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

+3 -1
View File
@@ -2481,7 +2481,9 @@ export function GameTable({
? 'Battle losses and mandatory card disposal are complete. These surviving advisors are now alone and must become fighters before the remaining cleanup.'
: g.response.automaticAdvisorFlip.producer === 'homeworldTransport'
? 'The Homeworld transport and payment are complete. These advisors are now alone and must become fighters before any remaining arrival.'
: 'The ground movement is complete. These advisors are now alone and must become fighters.'}
: g.response.automaticAdvisorFlip.producer === 'faceDance'
? 'Face Dance is complete: the winning army has returned, the replacement is placed and the Face Dancer is revealed. These surviving advisors are now alone and must become fighters before aftermath continues.'
: 'The ground movement is complete. These advisors are now alone and must become fighters.'}
{' '}Karama prevents only this conversion; the completed action and its costs stay committed.
{' '}Provisional rule: a prevented group stays advisors while continuously alone,
until another faction returns or the group leaves.
+61
View File
@@ -1,5 +1,66 @@
# Mandatory advisor conversions
## Completed Face Dance replacement — 24 October 2026
**Prototyped / Partial.** Compose the same supplied
physical17/28 E counter with native Face Dance's return/replacement (physical38 A).
Selecting forces from remote board territories can leave surviving BG advisors
alone there. Choosing zero replacement can leave the target's advisors alone
after the winning army returns. Consider all changed source territories and
the target after the complete physical transfer, before settlement benefits.
Original battle rewards and card cleanup keep their priority. Complete the
winning army's actual return, replacement placement, original leader death,
Face Dancer revelation and any all-three card cycle first. Face Dance itself
retains its printed immunity; the new response concerns the separate BG
advantage. Canceling conversion neither replays nor reverses replacement or
reveals another Face Dancer. Retain the existing provisional continuously-alone
hold and consume the parent before resuming original battle aftermath once.
The public phase6 receipt selects the original six-field battle identity,
replacement actor/target/sector, positive physical sources, replacement amount
and actual returned counts. It contains no secret Face Dancer identity, new
drawn card or wider private battle context. Current card availability is not
rechecked after revelation/cycling. A remote advisor group may belong to an
original combatant; the battle-loss-only bystander restriction does not apply
to a different territory. Existing Homeworld/native and co-side permissions
remain in the original replacement; this response grants none.
[Native fixture](../tests/fixture-advisor-face-dance-flip.ts) starts public
Advanced BG/Tleilaxu/Atreides in that seat order, original Ix47 and no optional
module. The real final advisorSetup action deals the original three Face Dancers
from a selected permutation of unused original identities. Actual earlier
Traitor choices, starting Treachery deal and the undealt card census remain.
Tleilaxu pays3 to ship three forces into Arrakeen, then really moves two to
Basin. A real unarmed Atreides win with dial/support1 leaves nine survivors.
The remote branch moves all ten Atreides to Basin first, then replaces the
survivors using the last remote Arrakeen counter and eight reserves. The zero
branch fights in Arrakeen and replaces none. Original Atreides Karama remains
available. No played army, hand, wallet, phase, Tanks, revealed flag or receipt
is installed.
[Engine cases](../tests/advisor-face-dance-flip-engine.test.ts) cover both
conversion outcomes, original decline/immunity, completed parent mismatch and
existing profiles. Force return, actual leader death, revelation and replacement
stay committed while the response waits; Collection depends on the final stance.
Frozen **16 files201/201**, types/lint/build, five native Face Dance/response/
GameTable SSR journeys and eight existing-profile paths pass. Remote and zero
sources take67/66 original actions. Both return nine actual Atreides survivors,
kill the original leader once without bounty and reveal the original card.
Allowed conversion earns BG two actual city income; prevention earns none.
Original decline leaves the army, leader and unrevealed card intact. Standard
controls render; this is not a new callback/browser/mobile/deployed certificate.
First native feedback corrects the expected original Ix deck count43 to47,
without changing any physical card/deal or rule. Its focused case and the final
frozen batch pass. Private proof and original-action HTML:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-face-dance-flips/`.
`frozen-integrated/report.json` binds tree
`78239a9def5dd70ae404d5a614a21ea09ea615dd2a6cde7d05760715416d7401`
on base `0b3413fcd8238301f97114171fcca2936adf51e5`. Prose/reference evidence is
reviewed and hashed separately after proof. Combined all-three cycling, Skills/Ecaz co-side/Homeworld/Nexus and other automatic producers remain
unfinished acceptance; focused proof does not open complete modes.
## Homeworld and Junction transport — 24 October 2026
**Prototyped / Partial.** Compose physical17/28 E's
+28 -6
View File
@@ -16,6 +16,34 @@ open mode gates.
## Current checkpoint and work
**Advisor conversion after Face Dance — 24 October. Prototyped / Partial.**
Completed replacement now captures surviving alone advisors at remote board
sources and zero-replacement targets. Original army return, replacement, leader
death, revelation and any card cycle complete first. Face Dance stays immune;
only the separate BG conversion can be prevented. Public physical/battle evidence
omits secret identities and never rechecks a now-revealed dancer. Consume the
parent before one original aftermath; preserve the existing continuously-alone
hold and actual Collection outcome.
[Source, native scenarios and limits](ADVISOR_AUTOMATIC_FLIPS.md#completed-face-dance-replacement--24-october-2026).
Disjoint helper/native-fixture lanes, bounded continuation review and root
integration pass **16 files201/201**, types/lint/build, five native Face Dance/
response/GameTable SSR journeys and eight existing-profile paths. Genuine67/66-
action public Advanced BG/Tleilaxu/Atreides sources earn original Ix47, a first
native Face Dancer deal, paid shipment, real ground moves, unarmed battle and
nine surviving Atreides. Remote one-plus-eight replacement and zero replacement
both retain the completed return/death/revelation; allowed BG conversion earns
city income2, prevention earns0. Original decline preserves the winning army
and unrevealed card. Feedback corrects a fixture's expected deck count43 to47,
without changing physical cards or game rules. Full cycle/Skills/Ecaz co-side/
Homeworld/Nexus combinations with this counter remain open. No AI refinement,
comprehensive/browser/deployed assurance or saved-room/D1/reset/restart/deployment
operation.
Next conversion work includes sector-specific storm losses and special
Ambassador/Discovery relocation. Matching-group interpretation and complete
Advanced/mode acceptance remain unfinished.
**Advisor conversion after Homeworld/Junction transport — 24 October. Prototyped / Partial.**
Native Guild board-to-world and Junction board transfers now capture resulting
alone advisors after typed transfer/payment and before settlement benefits.
@@ -38,12 +66,6 @@ Wider contributor/occupation/Nexus/Semuta combinations and remaining automatic
producers stay open. No AI refinement, comprehensive/browser/deployed assurance
or saved-room/D1/reset/restart/deployment operation.
Next concrete conversion gaps include remote Face Dance source departures,
sector-specific storm losses and special Ambassador/Discovery relocation.
Harass withdrawal and Diplomat retreat origin already use the battle capture
boundary; they do not need duplicate producers. Matching-group interpretation
and complete Advanced/mode acceptance remain unfinished.
**Advisor conversion after battle losses — 24 October. Prototyped / Partial.**
Actual battle losses can now leave surviving bystanding advisors in a sourced
Karama conversion response. Preserve casualties, payments, deaths and mandatory
+42
View File
@@ -1,5 +1,47 @@
# Dune implementation status
## 24 October 2026 — Advisor conversion after completed Face Dance
Supplied physical17/28 E's counter now follows physical38 A's completed native
return/replacement. Capture all positive board source territories and target,
including zero replacement. Preserve actual return, replacement, leader death,
revelation and any all-three card cycle before the BG response. Face Dance
itself stays immune. Retain the existing provisional continuously-alone hold,
then consume the parent before original battle aftermath resumes once.
An eight-field public physical/battle receipt omits secret Face Dancer identity,
newly drawn cards and private battle context. No pre-reveal availability is
rechecked after cycling. Remote BG groups may belong to original combatants;
the battle-loss-only bystander restriction remains separate. Actual returned
counts follow existing source consumption, including a Tleilaxu co-side army
that may have supplied selected counters. Existing Homeworld/co-side permissions
stay with the original replacement; no new entitlement is granted.
Disjoint helper/native-fixture lanes, bounded continuation review and root
integration pass **16 files201/201**, types/lint/build, five actual Face Dance/
response/GameTable SSR journeys and eight existing-profile paths. Genuine67/66-
action public Advanced BG/Tleilaxu/Atreides sources earn original Ix47, native
first Face Dancer draw, paid three-counter shipment, real ground moves and
actual unarmed battle. Remote one-plus-eight and zero replacement both return
nine original Atreides survivors, kill the original leader once without bounty
and reveal the original card. BG income is2 when allowed,0 when prevented.
Original decline retains winner, leader and unrevealed card. No played army,
phase, wallet, hand, Tanks, revealed flag or receipt is installed.
First native feedback corrects the expected original deck count43 to47; physical
cards, native deal and rules remain unchanged. The focused case and final frozen
proof pass. Private proof and original-action HTML:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-face-dance-flips/`.
Frozen `frozen-integrated/report.json` binds tree
`78239a9def5dd70ae404d5a614a21ea09ea615dd2a6cde7d05760715416d7401`,
2743 files on base `0b3413fcd8238301f97114171fcca2936adf51e5`.
Prose/reference overlays are separately reviewed/hashed. Standard controls
render; no new callback/browser/mobile/deployed or complete-mode claim. Full
all-three-cycle/Skills/Ecaz co-side/Homeworld/Nexus combinations with this counter,
storm/special relocation/other producers, comprehensive assurance and full AI
refinement remain due. No saved-room/D1, reset, restart or deployment operation.
[Connected source and limits](ADVISOR_AUTOMATIC_FLIPS.md#completed-face-dance-replacement--24-october-2026).
## 24 October 2026 — Advisor conversion after Homeworld/Junction transfer
Native Guild board-to-world and board-origin Junction transport now hold
+13
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Advisor conversion after Face Dance — 24 October 2026
Compose supplied Advanced physical17/28 E with physical38 A's completed
return/replacement. Capture actual surviving groups at positive board-source
territories and the target, including zero replacement. Original rewards/card
cleanup, physical returns, leader death, revelation and card cycle finish first.
Native Face Dance remains immune; only the later BG conversion can be prevented.
Retain the existing provisional continuously-alone duration. Public physical/
battle evidence omits private Face Dancers and is not a second availability
check after revelation. Consume the parent before original aftermath resumes
once; no replacement, death, reward or draw is repeated.
[Connected source and continuation](ADVISOR_AUTOMATIC_FLIPS.md#completed-face-dance-replacement--24-october-2026).
## Advisor conversion after Homeworld transport — 24 October 2026
Extend the existing physical17/28 E composition to native Guild board-to-world
+80 -12
View File
@@ -48,6 +48,17 @@ export type AutomaticAdvisorBattle = {
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
};
export type AutomaticAdvisorFaceDance = {
event: string;
battle: AutomaticAdvisorBattle;
player: string;
territory: string;
sector: number;
sources: Record<string, number>;
amount: number;
returned: Record<string, number>;
};
export type AutomaticAdvisorFlipSource = {
event: string;
owner: string;
@@ -61,6 +72,7 @@ export type AutomaticAdvisorFlipSource = {
| { producer: 'homeworldTransport'; transport: AutomaticAdvisorHomeworldTransport }
| { producer: 'gamont'; gamont: AutomaticAdvisorGamont }
| { producer: 'battleLosses'; battle: AutomaticAdvisorBattle }
| { producer: 'faceDance'; faceDance: AutomaticAdvisorFaceDance }
);
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
@@ -275,25 +287,23 @@ function validateHomeworldTransportSource(
}
}
function validateBattleSource(
function validatePublicBattleIdentity(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>,
receipt: AutomaticAdvisorBattle,
turn: number,
) {
const receipt = source.battle;
requireFlip(record(receipt) &&
Object.keys(receipt).sort().join(',') === 'combatants,event,result,territory,turn,winner' &&
identifier(receipt.event) && positive(receipt.turn) && receipt.turn === source.turn &&
identifier(receipt.territory) && receipt.territory === source.territory &&
identifier(receipt.event) && positive(receipt.turn) && receipt.turn === turn &&
identifier(receipt.territory) &&
Array.isArray(receipt.combatants) && receipt.combatants.length === 2 &&
receipt.combatants.every(identifier) && new Set(receipt.combatants).size === 2 &&
receipt.combatants.every((id) => ctx.players.filter((player) => player.id === id).length === 1) &&
!receipt.combatants.includes(source.owner) &&
['normal', 'traitor', 'mutualTraitors', 'explosion'].includes(receipt.result) &&
(receipt.result === 'normal' || receipt.result === 'traitor'
? identifier(receipt.winner) && receipt.combatants.includes(receipt.winner)
: receipt.winner === null) &&
gameTerritories(ctx).some((place) => place.id === receipt.territory),
'Automatic advisor conversion needs the original public battle result and bystander group.');
: receipt.winner === null),
'Automatic advisor conversion needs the original public battle result.');
const current = ctx.lastBattleContext;
requireFlip(!ctx.battle && record(current) && current.event === receipt.event &&
current.turn === receipt.turn && current.territory === receipt.territory &&
@@ -303,6 +313,60 @@ function validateBattleSource(
'Automatic advisor conversion lost its original completed battle context.');
}
function validateBattleSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>,
) {
validatePublicBattleIdentity(ctx, source.battle, source.turn);
requireFlip(source.battle.territory === source.territory &&
!source.battle.combatants.includes(source.owner) &&
gameTerritories(ctx).some((place) => place.id === source.battle.territory),
'Automatic advisor conversion needs the original public battle result and bystander group.');
}
function validateFaceDanceSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'faceDance' }>,
) {
const receipt = source.faceDance;
requireFlip(record(receipt) &&
Object.keys(receipt).sort().join(',') === 'amount,battle,event,player,returned,sector,sources,territory' &&
identifier(receipt.event) && identifier(receipt.player) && identifier(receipt.territory) &&
nonnegative(receipt.sector) && nonnegative(receipt.amount) && record(receipt.sources) &&
Object.values(receipt.sources).every(positive) &&
Object.values(receipt.sources).reduce((total, count) => total + count, 0) === receipt.amount &&
record(receipt.returned) && Object.values(receipt.returned).every(positive) &&
Object.keys(receipt.returned).every((id) =>
identifier(id) && ctx.players.filter((player) => player.id === id).length === 1),
'Automatic advisor conversion lost its exact public Face Dance receipt.');
validatePublicBattleIdentity(ctx, receipt.battle, source.turn);
const actors = ctx.players.filter((player) => player.id === receipt.player);
const actor = actors[0];
requireFlip(actors.length === 1 && actor.faction === 'tleilaxu' &&
receipt.battle.winner !== null && receipt.player !== receipt.battle.winner &&
(receipt.battle.result === 'normal' || receipt.battle.result === 'traitor') &&
receipt.territory === receipt.battle.territory,
'Automatic advisor conversion needs the original other winner and native Face Dance actor.');
const places = gameTerritories(ctx);
const destination = places.find((place) => place.id === receipt.territory && place.sectors.includes(receipt.sector));
const worldDestination = receipt.territory === 'homeworld:tleilaxu' && receipt.sector === 0 &&
record(ctx.homeworlds) && record(ctx.homeworlds.custody);
requireFlip((!!destination || worldDestination) && Object.keys(receipt.sources).every((key) => {
if (key === 'reserves') return true;
const point = splitLocation(key);
return key === `${point.territory}:${point.sector}` &&
places.some((place) => place.id === point.territory && place.sectors.includes(point.sector));
}) && ((!!destination && source.territory === receipt.territory) || Object.keys(receipt.sources).some((key) =>
key !== 'reserves' && splitLocation(key).territory === source.territory)),
'Automatic advisor conversion must use the Face Dance destination or a positive board source.');
if (destination) {
const current = actor.forces[`${receipt.territory}:${receipt.sector}`];
const deposited = current === undefined ? 0 : current;
requireFlip(nonnegative(deposited) && deposited >= receipt.amount,
'Automatic advisor conversion lost the Face Dance board replacement.');
}
}
/** Validate the same committed producer and exact held group before either response outcome. */
export function validateAutomaticAdvisorFlip(
ctx: AutomaticAdvisorFlipContext,
@@ -318,10 +382,13 @@ export function validateAutomaticAdvisorFlip(
(source.producer === 'gamont' &&
Object.keys(source).sort().join(',') === 'event,forces,gamont,owner,phase,producer,territory,turn') ||
(source.producer === 'battleLosses' &&
Object.keys(source).sort().join(',') === 'battle,event,forces,owner,phase,producer,territory,turn')) &&
Object.keys(source).sort().join(',') === 'battle,event,forces,owner,phase,producer,territory,turn') ||
(source.producer === 'faceDance' &&
Object.keys(source).sort().join(',') === 'event,faceDance,forces,owner,phase,producer,territory,turn')) &&
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
positive(source.turn) && source.turn === ctx.turn &&
source.phase === (source.producer === 'gamont' ? 8 : source.producer === 'battleLosses' ? 6 : 5) &&
source.phase === (source.producer === 'gamont' ? 8 :
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5) &&
currentWindow(ctx) && source.phase === ctx.phase,
'Automatic advisor conversion lost its original current-turn committed producer.');
const owners = ctx.players.filter((player) => player.id === source.owner);
@@ -338,7 +405,8 @@ export function validateAutomaticAdvisorFlip(
} else if (source.producer === 'guildTransport') validateTransportSource(ctx, source);
else if (source.producer === 'homeworldTransport') validateHomeworldTransportSource(ctx, source);
else if (source.producer === 'gamont') validateGamontSource(ctx, source);
else validateBattleSource(ctx, source);
else if (source.producer === 'battleLosses') validateBattleSource(ctx, source);
else validateFaceDanceSource(ctx, source);
const forces = physicalGroup(owner, source.territory);
requireFlip(record(source.forces) && Object.keys(forces).length > 0 &&
Object.values(forces).every(positive) && Object.values(source.forces).every(positive) &&
+76 -4
View File
@@ -12,7 +12,7 @@ import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, h
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteAtreidesFullPlan } from './atreides-full-plan';
import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama';
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport } from './advisor-automatic-flip';
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance } from './advisor-automatic-flip';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
@@ -1276,6 +1276,7 @@ export type Game = {
arrival?: { territory: string; sector: number; amount: number; elite: number };
signature: string;
};
pendingAutomaticAdvisorFaceDance?: AutomaticAdvisorFaceDance;
advisorPreparation?: AdvisorPreparationWindow;
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
@@ -20322,6 +20323,12 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
'The mandatory conversion has lost its original committed Homeworld transport.');
validateHomeworldAdvisorTransport(g, parent);
}
if (source.producer === 'faceDance') {
const parent = g.pendingAutomaticAdvisorFaceDance;
requireRule(parent && JSON.stringify(source.faceDance) === JSON.stringify(parent),
'The mandatory conversion has lost its original completed Face Dance.');
validateCompletedFaceDance(g, parent);
}
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
return source;
}
@@ -20533,6 +20540,59 @@ function finishAutomaticAdvisorHomeworldTransport(g: Game) {
openTerritoryEntry(g, actor, to, sector, amount, elite, 'shipment');
}
}
function prepareFaceDanceAdvisorFlips(g: Game, player: Player, to: string, sector: number,
sources: Record<string, number>, returned: Record<string, number>) {
const context = g.lastBattleContext;
if (!g.advanced || g.status !== 'playing' || g.phase !== 6 || !context ||
(context.result !== 'normal' && context.result !== 'traitor')) return false;
const changed = new Set([to, ...Object.entries(sources)
.filter(([key, count]) => key !== 'reserves' && count > 0).map(([key]) => splitLocation(key).territory)]);
const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g))
.filter(group => changed.has(group.territory));
if (!groups.length) return false;
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorFaceDance &&
!g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorTransport &&
!g.pendingAutomaticAdvisorHomeworldTransport && !g.pendingAutomaticAdvisorGamont,
'Finish the original advisor conversion before another Face Dance.');
const receipt: AutomaticAdvisorFaceDance = { event: crypto.randomUUID(),
battle: { event: context.event, turn: context.turn, territory: context.territory,
combatants: [...context.combatants], winner: context.winner, result: context.result },
player: player.id, territory: to, sector, sources, returned,
amount: Object.values(sources).reduce((sum, count) => sum + count, 0) };
g.pendingAutomaticAdvisorFaceDance = receipt;
for (const group of groups) {
const source: AutomaticAdvisorFlipSource = { event: crypto.randomUUID(), owner: group.player,
territory: group.territory, turn: g.turn, phase: g.phase, forces: group.forces,
producer: 'faceDance', faceDance: receipt };
getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event;
(g.pendingAutomaticAdvisorFlips ??= []).push(source);
}
return true;
}
function validateCompletedFaceDance(g: Game, receipt: AutomaticAdvisorFaceDance) {
requireRule(g.status === 'playing' && g.phase === 6 && !g.battle && !g.pendingFaceDance &&
receipt.battle.event === g.lastBattleContext?.event,
'Finish the original Face Dance reveal, replacement and card cycle before conversion.');
const player = getPlayer(g, receipt.player);
const deposited = receipt.territory.startsWith('homeworld:')
? homeworldRule(() => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!))
.find(world => world.id === receipt.territory)?.forces[player.id]
: null;
const amount = receipt.territory.startsWith('homeworld:')
? (deposited?.normal ?? 0) + (deposited?.elite ?? 0)
: (player.forces[location(receipt.territory, receipt.sector)] ?? 0);
requireRule(amount >= receipt.amount && Object.entries(receipt.returned)
.every(([owner, count]) => getPlayer(g, owner).reserves >= count),
'The completed Face Dance lost its original returned or replacement counters.');
}
function finishAutomaticAdvisorFaceDance(g: Game) {
const receipt = g.pendingAutomaticAdvisorFaceDance;
if (!receipt) return;
requireRule(!g.pendingAutomaticAdvisorFlips?.length, 'Finish all original Face Dance conversions before aftermath.');
validateCompletedFaceDance(g, receipt);
delete g.pendingAutomaticAdvisorFaceDance;
finishBattle(g);
}
function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
for (const entry of released)
delete getPlayer(g, entry.player).advisors?.[entry.territory];
@@ -22552,7 +22612,8 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
function victory(g: Game, quote?: VictoryQuote) {
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport,
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
!g.pendingAutomaticAdvisorFaceDance,
'Resolve mandatory advisor conversions before checking victory.');
quote ??= currentVictoryQuote(g);
applyAdvisorReleases(g, quote.released);
@@ -28374,7 +28435,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
}
if (automaticAdvisorFlip) {
if (canceled)
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
{ faction: bg.faction, name: 'Mandatory advisor conversion prevented' });
g.pendingAutomaticAdvisorFlips!.shift();
if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips;
@@ -28383,6 +28444,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
finishAutomaticAdvisorGamont(g);
finishAutomaticAdvisorBattle(g);
finishAutomaticAdvisorHomeworldTransport(g);
finishAutomaticAdvisorFaceDance(g);
} else openAutomaticAdvisorFlip(g);
} else finishAdvisorReaction(g, response);
}
@@ -33758,6 +33820,7 @@ function applyActionInner(
typeof action.reveal === 'boolean',
'Reveal a Face Dancer or decline.',
);
let heldConversion = false;
if (action.reveal) {
const dancer = p.faceDancers?.find(
(c) => !c.revealed && c.leader === decision.identity,
@@ -33810,6 +33873,12 @@ function applyActionInner(
if (!p.forces[source.key]) delete p.forces[source.key];
}
}
// Record the counters actually returned by the existing transfer. A
// co-side Tleilaxu army may already have supplied selected counters.
const returned = Object.fromEntries((home
? [[winner.id, army.normal + army.elite] as const]
: winningArmies.map(owner => [owner.id, at(owner, decision.territory)] as const))
.filter(([, count]) => count > 0));
if (home) {
const destination = `homeworld:${winner.faction}`;
const external = selected.filter((source) => source.key !== 'reserves').reduce((n, source) => n + source.count, 0);
@@ -33848,6 +33917,8 @@ function applyActionInner(
delete leader.concealed;
}
if (total && !home) place(p, decision.territory, sector, total);
heldConversion = prepareFaceDanceAdvisorFlips(g, p, decision.territory, sector,
Object.fromEntries(selected.filter(source => source.count > 0).map(source => [source.key, source.count])), returned);
observeOccupation(g);
dancer.revealed = true;
log(
@@ -33869,7 +33940,8 @@ function applyActionInner(
const harkonnen = byFaction(g, 'harkonnen');
if (harkonnen) returnCaptives(g, harkonnen);
settleAdvisors(g);
finishBattle(g);
if (heldConversion) openAutomaticAdvisorFlip(g);
else finishBattle(g);
} else if (decision.kind === 'diplomatRetreat') {
const receipt = g.battle?.diplomatRetreat;
const destination = action.destination;
+1
View File
@@ -185,6 +185,7 @@ const cancellationSources = {
gamont: response.automaticAdvisorFlip.producer === 'gamont' ? g.pendingAutomaticAdvisorGamont?.receipt ?? null : null,
battle: response.automaticAdvisorFlip.producer === 'battleLosses' ? g.pendingAutomaticAdvisorBattle?.receipt ?? null : null,
homeworld: response.automaticAdvisorFlip.producer === 'homeworldTransport' ? g.pendingAutomaticAdvisorHomeworldTransport?.receipt ?? null : null,
faceDance: response.automaticAdvisorFlip.producer === 'faceDance' ? g.pendingAutomaticAdvisorFaceDance ?? null : null,
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
: response.advisorPreparationSource
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
+13 -6
View File
@@ -2094,7 +2094,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'A winning Cheap Hero may be a Face Dancer. Kwisatz Haderach does not protect an accompanying leader against Face Dancing. Karama cannot cancel the reveal or the replacement army.',
'Keep revealed Face Dancers face up until all three have been used. Shuffle those cards back into the unused deck and draw three new ones. During Mentat Pause, you may instead replace one unrevealed Face Dancer; this once-per-turn exchange has a Karama response.',
'Zoal has variable battle strength: he copies the opposing leader disc, without any KH bonus, or counts zero against a Cheap Hero. His death bounty uses that copied strength. His ordinary revival value is three spice before discounts.',
'The core setup, reveal sequence, card cycling, Zoal effects and AI controls are tested. Tleilaxu revival commerce also has tested support. Foreign ghola acquisition and buyback controls are also implemented. Their special Karama now also has tested support; its timing and the complete expansion interaction audit are still unfinished, so expansion starts remain disabled.',
'After completed replacement, advisors left alone at remote sources or a zero-replacement target have a separate BG conversion response. Face Dance remains complete and immune; prevention keeps its returns, replacement, leader death and revelation/cycle, then resumes the original aftermath once.',
'The core setup, reveal sequence, card cycling, Zoal effects and AI controls are tested. Tleilaxu revival commerce also has tested support. Foreign ghola acquisition and buyback controls are also implemented. Their special Karama now also has tested support; its timing and the complete expansion interaction audit are still unfinished, so starts remain labelled previews.',
],
related: [
'battle',
@@ -2102,6 +2103,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'tech-tokens',
'mentat',
'ix-modules',
'advanced-advisors',
],
},
@@ -4317,6 +4319,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A committed Guild cross-shipment or return to reserves can also leave advisors alone. Original transport permission, rate and any Guild income resolve first; payment, force transfer and shipment use stay committed if the later conversion is prevented. The ordinary ground move remains unused. Cross-shipment arrival follows the conversion once; a reserve return has no arrival.',
'After actual battle losses leave surviving bystanding advisors alone, Karama may prevent their conversion without undoing losses or costs. Mandatory card disposal, original skills and substitution finish first. Provisionally resolve this before optional winner-card cleanup and later aftermath, then resume the original cleanup once. An explosion that destroys the advisors creates no conversion.',
'Native Guild board-to-Homeworld and Junction board transfers can leave advisors alone after payment and physical transfer. Karama prevents only the resulting BG conversion; Junction permission and tariff stay immune. Original income resolves first, then any Junction board arrival resumes once. Original declaration snapshots stay private.',
'A completed Face Dance can leave advisors alone at remote board sources or, with zero replacement, at the battle target. Original force returns, replacement, leader death, revelation and any card cycle finish first. Karama can then prevent only the BG conversion; native Face Dance remains immune. Afterward the original aftermath resumes once.',
'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Other non-ground automatic conversions and matching-group flip counters remain unfinished.',
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
@@ -4328,7 +4331,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Implementation",
"status": "Partial",
"detail": "Homeworld/Junction board transfers now capture alone groups before occupation/settlement, retain completed typed transfer/payment and shipment use, keep original snapshot events private and resume only original Junction board arrival after the consumed parent. Existing ground/Guild transport/Gamont/battle/preparation sources retain separate exact receipts and timing. Other automatic producers and matching-group counters remain unfinished.",
"detail": "Completed Face Dance now captures alone advisors at all positive board sources and its target, preserving return/replacement, leader death and revelation/cycling before one original aftermath. Its public physical/battle receipt omits Face Dancer identities and does not reuse pre-reveal availability. Existing ground, Guild/Homeworld/Junction transport, Gamont, battle and preparation sources retain their contracts. Storm and other special relocation/matching-group causes remain unfinished.",
"evidence": [
"game/advisor-automatic-flip.ts",
"game/advisors.ts",
@@ -4343,7 +4346,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Player controls",
"status": "Partial",
"detail": "Standard Karama/Allow controls distinguish completed Homeworld transport/payment from the pending BG conversion and remaining arrival. Existing ground, ordinary transport, Gamont, battle and phase2–5 preparation guidance retain their contracts. No new confirmation or timer.",
"detail": "Standard Karama/Allow controls distinguish completed Face Dance from the pending BG conversion. Existing transport, ground, Gamont, battle and phase2–5 preparation guidance remain. No new confirmation or timer.",
"evidence": [
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"components/advisor-preparation.tsx",
@@ -4354,8 +4357,10 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "AI",
"status": "Partial",
"detail": "Sixteen existing-profile native Homeworld/Junction response paths accept legal actions without replaying transfer/payment or consuming ordinary movement. Prior ground/Guild transport/Gamont/battle/preparation paths retain separate evidence. No strategy refinement or difficulty calibration.",
"detail": "Eight existing-profile native remote/zero Face Dance response paths resolve legally through actual Collection, preserving completed return, death, revelation and replacement. Earlier producer paths retain separate evidence. No strategy refinement or difficulty calibration.",
"evidence": [
"tests/advisor-face-dance-flip-engine.test.ts",
"tests/fixture-advisor-face-dance-flip.ts",
"tests/advisor-homeworld-flip-engine.test.ts",
"tests/fixture-advisor-homeworld-flip.ts",
"tests/advisor-battle-flip-engine.test.ts",
@@ -4370,7 +4375,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Documentation",
"status": "Partial",
"detail": "Physical17/28 E composes the group counter with mandatory conversion. Homeworld/Junction guidance distinguishes immune transport from BG conversion, private original snapshots, public physical evidence and one original arrival. Continuously-alone duration and earlier battle priority remain provisional. Remaining producers and combinations stay explicit.",
"detail": "Physical17/28 E composes the group counter with physical38 A after completed return/replacement. Face Dance immunity, actual returns, remote sources, zero replacement, original card cycle and private holdings are distinguished from the BG response. Continuously-alone duration remains provisional; other producers/combinations stay explicit.",
"evidence": [
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"docs/ADVISOR_PREPARATION.md",
@@ -4381,8 +4386,10 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Verification",
"status": "Partial",
"detail": "Homeworld/Junction passes frozen18-file218/218, types/lint/build, nine native transport/response/GameTable SSR journeys and sixteen profile paths. Genuine37/38/42/36-action sources earn original native/Junction returns, destination Intrusion and BG empty arrival. Costs3/5/10/1 remain spent through both outcomes; earlier native permission prevention stays uncommitted. A fixture-variable lint repair preserves strict source validation. Earlier producer proofs remain linked; wider contributor/occupation/Nexus/Semuta, remote Face Dance/storm/other producers, full modes and browser/mobile/deployed/comprehensive acceptance remain open.",
"detail": "Face Dance passes frozen16-file201/201, types/lint/build, five native Face Dance/response/GameTable SSR journeys and eight profile paths. Genuine67/66-action sources earn original Ix47, native draw, shipment, moves and battle. Both outcomes preserve nine returned survivors and one leader death; actual BG city income is2/0. Original decline stays intact. A fixture deck-count correction changes no cards/rules. Full cycle/Skills/Ecaz co-side/Homeworld/Nexus combinations, storm/special relocation, complete modes and browser/mobile/deployed/comprehensive acceptance remain open.",
"evidence": [
"tests/advisor-face-dance-flip-engine.test.ts",
"tests/fixture-advisor-face-dance-flip.ts",
"tests/advisor-homeworld-flip-engine.test.ts",
"tests/fixture-advisor-homeworld-flip.ts",
"tests/advisor-battle-flip-engine.test.ts",
+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));
});
@@ -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);
}
});
+278
View File
@@ -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);
}