From 9bce22abf5286804f1bc2ad2df4afd90257c7cd8 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:00:58 +1100 Subject: [PATCH] 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. --- components/game-table.tsx | 4 +- docs/ADVISOR_AUTOMATIC_FLIPS.md | 61 ++++ docs/CURRENT_STATUS.md | 34 ++- docs/IMPLEMENTATION_STATUS.md | 42 +++ docs/RULE_DECISIONS.md | 13 + game/advisor-automatic-flip.ts | 92 +++++- game/engine.ts | 80 +++++- game/karama-context.ts | 1 + game/reference.ts | 19 +- tests/advisor-automatic-flip-quote.test.ts | 199 +++++++++++++ tests/advisor-face-dance-flip-engine.test.ts | 107 +++++++ tests/fixture-advisor-face-dance-flip.ts | 278 +++++++++++++++++++ 12 files changed, 901 insertions(+), 29 deletions(-) create mode 100644 tests/advisor-face-dance-flip-engine.test.ts create mode 100644 tests/fixture-advisor-face-dance-flip.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 2aca9363..0779f60c 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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. diff --git a/docs/ADVISOR_AUTOMATIC_FLIPS.md b/docs/ADVISOR_AUTOMATIC_FLIPS.md index d95934d4..634a2ed4 100644 --- a/docs/ADVISOR_AUTOMATIC_FLIPS.md +++ b/docs/ADVISOR_AUTOMATIC_FLIPS.md @@ -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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 10b7212e..9c386db5 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index fc59b7b9..147c7bda 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 44e027d0..26c6951c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/game/advisor-automatic-flip.ts b/game/advisor-automatic-flip.ts index 9a25e204..31f66d01 100644 --- a/game/advisor-automatic-flip.ts +++ b/game/advisor-automatic-flip.ts @@ -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; + amount: number; + returned: Record; +}; + 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, + 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, +) { + 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, +) { + 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) && diff --git a/game/engine.ts b/game/engine.ts index 357046b4..0e522d77 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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, returned: Record) { + 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; diff --git a/game/karama-context.ts b/game/karama-context.ts index a55a9149..355e9052 100644 --- a/game/karama-context.ts +++ b/game/karama-context.ts @@ -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 } diff --git a/game/reference.ts b/game/reference.ts index 517be639..244cf909 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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", diff --git a/tests/advisor-automatic-flip-quote.test.ts b/tests/advisor-automatic-flip-quote.test.ts index b22c1ae3..a9454e99 100644 --- a/tests/advisor-automatic-flip-quote.test.ts +++ b/tests/advisor-automatic-flip-quote.test.ts @@ -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; type BattleSource = Extract; type HomeworldSource = Extract; +type FaceDanceSource = Extract; 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)); +}); diff --git a/tests/advisor-face-dance-flip-engine.test.ts b/tests/advisor-face-dance-flip-engine.test.ts new file mode 100644 index 00000000..36279414 --- /dev/null +++ b/tests/advisor-face-dance-flip-engine.test.ts @@ -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); + } +}); diff --git a/tests/fixture-advisor-face-dance-flip.ts b/tests/fixture-advisor-face-dance-flip.ts new file mode 100644 index 00000000..c546c3bd --- /dev/null +++ b/tests/fixture-advisor-face-dance-flip.ts @@ -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 = (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 | 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 = 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; + +/** 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); +}