From d83db397c0365421034a53315c5604ae1c525177 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:37:09 +1100 Subject: [PATCH] Resolve advisor conversion after battle losses before optional cleanup Preserve committed casualties, payments and mandatory disposal when battle losses leave BG bystanders alone. Bind the public phase-six receipt to the original battle and resume its private winner/aftermath cursor once. Record provisional priority; explosions with no surviving advisors create no response. Repair stale Semuta fixtures to acknowledge the existing final battle window; a representative failure reproduces against unchanged base035be51. Production Semuta guards stay intact. Validation: 16 affected files264/264, types/lint/build, five native battle and GameTable SSR journeys, eight existing-profile response paths. Genuine44-action sources earn original cards/forces and actual Collection outcomes. Broader combinations and complete modes remain unfinished. Not deployed; no room/D1, reset or restart operation. Runtime fingerprint 665defe51a1f46a6b5992b6e1e4b034203f91e7101acf860eb549f5bf9865151; checkpoint prose/reference overlays are reviewed separately. --- components/game-table.tsx | 4 +- docs/ADVISOR_AUTOMATIC_FLIPS.md | 68 +++++ docs/CURRENT_STATUS.md | 33 +- docs/IMPLEMENTATION_STATUS.md | 45 +++ docs/RULE_DECISIONS.md | 15 + game/advisor-automatic-flip.ts | 58 +++- game/engine.ts | 94 +++++- game/karama-context.ts | 1 + game/reference.ts | 22 +- tests/advisor-automatic-flip-quote.test.ts | 152 +++++++++- tests/advisor-battle-flip-engine.test.ts | 128 ++++++++ tests/fixture-advisor-battle-flip.ts | 284 ++++++++++++++++++ tests/fixture-semuta-battle-karama.ts | 2 + .../fixture-semuta-homeworld-battle-loser.ts | 5 +- .../fixture-semuta-homeworld-battle-winner.ts | 5 +- tests/fixture-semuta-homeworld-choam.ts | 14 + tests/semuta-homeworld-battle-winner.test.ts | 4 +- 17 files changed, 908 insertions(+), 26 deletions(-) create mode 100644 tests/advisor-battle-flip-engine.test.ts create mode 100644 tests/fixture-advisor-battle-flip.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 2178c4a6..909764b0 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2477,7 +2477,9 @@ export function GameTable({ ? 'The transport and payment are complete. These advisors are now alone and must become fighters.' : g.response.automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont is complete and its card use stays spent. These advisors are now alone and must become fighters before victory is checked.' - : 'The ground movement is complete. These advisors are now alone and must become fighters.'} + : g.response.automaticAdvisorFlip.producer === 'battleLosses' + ? 'Battle losses and mandatory card disposal are complete. These surviving advisors are now alone and must become fighters before the remaining cleanup.' + : '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 55c44f41..1bbdefde 100644 --- a/docs/ADVISOR_AUTOMATIC_FLIPS.md +++ b/docs/ADVISOR_AUTOMATIC_FLIPS.md @@ -1,5 +1,73 @@ # Mandatory advisor conversions +## Battle losses before optional cleanup — 24 October 2026 + +**Prototyped / Partial.** Compose the supplied +Advanced source's physical17 group-flip counter with physical28 E's mandatory +conversion when alone. Ordinary battle losses can leave surviving bystanding +advisors alone even when neither combatant keeps a force. Preserve the completed +battle result, payments, deaths and actual casualty allocation. An explosion +that destroys the advisors leaves no group to convert. + +The selected provisional priority is after actual casualties (including Suk +Graduate's physical rescue), original skills/substitution and mandatory Treachery +disposal, before optional winner-card cleanup and subsequent aftermath effects. +This is a composition under the 14 October recommended-policy authority, not a newly retrieved publisher +priority ruling. Resolving the occurrence before later reinforcement/replacement +avoids silently losing an already-earned conversion opportunity. + +Capture immediate no-winner losses after the original battle closes, retaining +the advisor stance through mandatory disposal. Winner casualties capture the +group before skills and compulsory winner disposal; their original choices +finish before the response opens. The public phase6 source selects only the +original event, turn, territory, two combatants, winner and result, bound to the +exact surviving BG group. Cleanup cards and the winner/aftermath cursor stay in +the private parent, outside the response and Karama source selection. + +After the counter, consume that parent before resuming the exact winner or +aftermath cursor once. Never call battle resolution or apply casualties again. +Preventing conversion preserves losses/costs and the existing provisional +continuously-alone hold; it neither restores armies nor skips original cleanup. +The original standard Karama/Allow controls explain this boundary. Other +withdrawals, special arrivals and matching-group conversions remain open scope. + +[Native battle fixture](../tests/fixture-advisor-battle-flip.ts) starts public +Advanced BG/Atreides/Harkonnen with original base33. Original advisorSetup places +the peaceful counter beside Atreides' ten Arrakeen forces. Actual movement leaves +one Atreides there, then brings one Harkonnen counter from Carthag by city range. +Original Thufir5/dial1/support1 and Shield defeat Iakin2/dial0, leaving neither +army. A second original pre-deal Traitor permutation and actual private choice +earn mutual calls; both armies die and Shield's mandatory disposal precedes the +conversion. A third original Lasgun/Shield deal destroys the advisor too. No +played force, phase, hand, wallet, Tanks, stance or receipt is installed. + +[Engine cases](../tests/advisor-battle-flip-engine.test.ts) exercise both response +outcomes and exact original cleanup, prevention holds through Collection, +public/private parent mismatch rejection, no-survivor explosion and existing +profiles. Frozen **16 files264/264**, types/lint/build, five native battle/ +response/GameTable SSR journeys and eight existing-profile paths pass. Each +original source takes44 actual actions. Both battle outcomes reach actual +Collection with two BG city income when allowed, none when prevented; actual +Lasgun/Shield kills the advisor and opens no conversion. Standard existing +controls render; this is not a new callback/browser/mobile/deployed certificate. + +The first frozen223/264 feedback found41 old Semuta battle cases expecting +physical disposal before the existing final battle acknowledgements. A +representative failure reproduced against unchanged base035be51 in isolated +source, without copying saved games. Four focused fixtures now acknowledge only +the original final window; two remaining direct-test callers receive the same +repair. Focused3/3 then2/2 and the final264/264 pass, preserving original card/ +force/continuation assertions and all production Semuta guards. + +Private reports, baseline comparison, rendered HTML and hashes: +`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-battle-flips/`. +`frozen-integrated-3/report.json` binds fingerprint +`665defe51a1f46a6b5992b6e1e4b034203f91e7101acf860eb549f5bf9865151` +on base `035be5191510333951bb3be9692ecfe3a233eace`. Checkpoint prose/reference +updates are reviewed and hashed separately after proof. +Full Suk/Rihani/Caladan/Face Dance/Semuta combinations are not certified by these +native base scenarios; wider interaction and final assurance work remains due. + ## Trip to Gamont before Mentat victory — 24 October 2026 **Prototyped / Partial.** Compose supplied physical29 B’s return of one other diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 4616f629..e730f356 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 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 +card disposal. Original skill/substitution handling retains priority, then the +counter precedes optional winner-card cleanup and later aftermath under an +explicit provisional ordering. Public identity/outcome stays separate from the +private winner/aftermath cursor; consume it before resuming once. Prevention +retains the existing continuously-alone hold and changes actual Collection. +[Source, native scenarios and limits](ADVISOR_AUTOMATIC_FLIPS.md#battle-losses-before-optional-cleanup--24-october-2026). + +Disjoint helper/native-fixture lanes, bounded continuation review and root +integration pass **16 files264/264**, types/lint/build, five native battle/response/ +GameTable SSR journeys and eight legal existing-profile paths. Genuine44-action +public BG/Atreides/Harkonnen sources earn the original advisor, last-counter +battle and original Shield/Karama. Winner dial-all and mutual Traitors preserve +both armies' actual losses; allowance earns BG two city income, prevention earns +none. An actual Lasgun/Shield explosion destroys the advisor and creates no +conversion. Feedback repairs stale Semuta fixtures that skipped existing final +battle acknowledgements; the representative failure also reproduced against +unchanged base035be51. No production Semuta guard is relaxed. Full skill, +reinforcement/replacement/Semuta combinations and other producers remain open. +No AI refinement, comprehensive/browser/deployed assurance or saved-room/D1/ +reset/restart/deployment operation. + +Next native conversion work includes other withdrawals, special arrivals and +matching-group flips. Battle, ground, transport and Gamont proofs do not certify +all causes or complete Advanced modes. + **Advisor conversion after Trip to Gamont — 24 October. Prototyped / Partial.** Gamont now completes its original card use and return/revelation before the resulting mandatory advisor-conversion counter. Preventing conversion preserves @@ -39,11 +67,6 @@ combinations and remaining mandatory producers stay open. No AI refinement, comprehensive/browser/deployed assurance or saved-room/D1/reset/restart/deployment operation. -Next native conversion producers include casualties/aftermath, other withdrawals -and special arrivals. Preserve the original aftermath and collection continuations -before extending this counter; ground, transport and Gamont proofs do not certify -all mandatory conversion causes. - **Advisor conversion after committed Guild transport — 24 October. Prototyped / Partial.** Guild cross-shipment and reserve returns now retain the original paid transfer and shipment use before mandatory advisor-conversion prevention. An exact public diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index fb06d7f1..d7ec2c9d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,50 @@ # Dune implementation status +## 24 October 2026 — Advisor conversion after committed battle losses + +Supplied Advanced physical17/28 E now compose at native battle loss boundaries. +Capture surviving BG bystanders after actual losses, including immediate +no-winner losses before mandatory disposal. Preserve original skills, +substitution and compulsory winner disposal, then open conversion before optional +winner-card cleanup and later aftermath. That priority is explicit provisional +policy. An explosion destroying the advisors creates no occurrence. + +A strict phase6 source selects only original event/turn/territory/combatants/ +winner/result and the exact held group. Private cleanup cards and the winner/aftermath cursor, checked against its +stored signature, never enter the public response or Karama source selection. Consume the parent before resuming its exact cursor once; no replayed +resolution, casualties, payment or disposal. Prevented conversion keeps the +existing provisional continuously-alone hold through Collection. + +Disjoint pure-helper/native-fixture lanes, read-only continuation review and +root integration pass **16 files264/264**, types/lint/build, five actual native +battle/response/GameTable SSR journeys and eight legal existing-profile paths. +Genuine44-action public Advanced BG/Atreides/Harkonnen sources earn the original +advisor, real nine-counter departure and one-counter city arrival, original +Thufir/Iakin plans and Shield/Karama. Winner dial-all and mutual Traitors leave +both armies gone. Allowance earns two BG city income; prevention earns none. +The original Lasgun/Shield variant kills the advisor too. No played force, +phase, hand, wallet, Tanks, stance, prevention or receipt is installed. + +Initial223/264 feedback found41 stale Semuta battle cases skipping the existing +final outcome acknowledgements. A representative case also fails against the +unchanged base. Four fixture repairs preserve original actions/assertions and +stop at the actual fresh discard. Two remaining direct callers need the same +acknowledgement; focused3/3 then2/2 and the final264/264 pass. Production Semuta +availability/continuation guards stay intact. One initial new-test optional +absence assertion was also corrected; its focused native case then passed. + +Private proof, baseline comparison and original-action HTML: +`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-battle-flips/`. +Frozen `frozen-integrated-3/report.json` binds tree +`665defe51a1f46a6b5992b6e1e4b034203f91e7101acf860eb549f5bf9865151`, +2739 files on base `035be5191510333951bb3be9692ecfe3a233eace`. +Prose/reference overlays are separately reviewed/hashed. Standard response +controls render; no new callback/browser/mobile/deployed or complete-mode claim. +Full Suk/Rihani/Caladan/Face Dance/Semuta combinations, other automatic producers, +comprehensive assurance and full AI refinement remain due. No saved-room/D1, +reset, restart or deployment operation. +[Source and connected behavior](ADVISOR_AUTOMATIC_FLIPS.md#battle-losses-before-optional-cleanup--24-october-2026). + ## 24 October 2026 — Gamont conversion before final victory Compose the physical17/28 E advisor counter with physical29 B’s Mentat return. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index b0ec286f..225c7e0b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,20 @@ # Rule decision index +## Advisor conversion after battle losses — 24 October 2026 + +Compose supplied Advanced physical17's group counter with physical28 E's alone +conversion after actual battle losses. Preserve the completed result, payments, +deaths, casualty choices and mandatory disposal. Under the 14 October policy, +resolve this conversion after original skill/substitution handling and before +optional winner-card cleanup and later aftermath; +that priority is provisional, not an explicit combined publisher ruling. +Immediate no-winner losses retain their occurrence through original disposal. +The public phase6 receipt omits private cleanup cards/cursor; consume the parent +before resuming that exact cursor once, without replaying resolution or losses. +An explosion with no surviving advisors produces no conversion. Retain the +existing provisional continuously-alone duration and unfinished producer scope. +[Source and continuation contract](ADVISOR_AUTOMATIC_FLIPS.md#battle-losses-before-optional-cleanup--24-october-2026). + ## Advisor conversion after Trip to Gamont — 24 October 2026 Compose supplied physical29 B’s Mentat return with physical17’s group counter and diff --git a/game/advisor-automatic-flip.ts b/game/advisor-automatic-flip.ts index 5b95bec8..50436d44 100644 --- a/game/advisor-automatic-flip.ts +++ b/game/advisor-automatic-flip.ts @@ -28,6 +28,15 @@ export type AutomaticAdvisorGamont = { mentat: boolean; }; +export type AutomaticAdvisorBattle = { + event: string; + turn: number; + territory: string; + combatants: string[]; + winner: string | null; + result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion'; +}; + export type AutomaticAdvisorFlipSource = { event: string; owner: string; @@ -39,6 +48,7 @@ export type AutomaticAdvisorFlipSource = { | { producer: 'groundMovement'; mover: string; move: number } | { producer: 'guildTransport'; transport: AutomaticAdvisorTransport } | { producer: 'gamont'; gamont: AutomaticAdvisorGamont } + | { producer: 'battleLosses'; battle: AutomaticAdvisorBattle } ); export type AutomaticAdvisorFlipPlayer = ForcePresence & { @@ -60,6 +70,15 @@ export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard turn: number; phase: number; choamMentatPending?: boolean; + lastBattleContext?: { + event: string; + turn: number; + territory: string; + combatants: readonly string[]; + winner: string | null; + result: string; + } | null; + battle?: unknown; }; export class AutomaticAdvisorFlipError extends Error { @@ -93,7 +112,7 @@ function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFl function currentWindow(ctx: AutomaticAdvisorFlipContext) { return ctx.advanced === true && ctx.status === 'playing' && - (ctx.phase === 5 || ctx.phase === 8) && positive(ctx.turn); + (ctx.phase === 5 || ctx.phase === 6 || ctx.phase === 8) && positive(ctx.turn); } /** A supported committed producer opens a response for these current groups. @@ -199,6 +218,34 @@ function validateGamontSource( 'Automatic advisor conversion lost its final CHOAM Mentat opportunity.'); } +function validateBattleSource( + ctx: AutomaticAdvisorFlipContext, + source: Extract, +) { + 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 && + 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.'); + const current = ctx.lastBattleContext; + requireFlip(!ctx.battle && record(current) && current.event === receipt.event && + current.turn === receipt.turn && current.territory === receipt.territory && + current.winner === receipt.winner && current.result === receipt.result && + Array.isArray(current.combatants) && current.combatants.length === receipt.combatants.length && + current.combatants.every((id, index) => id === receipt.combatants[index]), + 'Automatic advisor conversion lost its original completed battle context.'); +} + /** Validate the same committed producer and exact held group before either response outcome. */ export function validateAutomaticAdvisorFlip( ctx: AutomaticAdvisorFlipContext, @@ -210,10 +257,12 @@ export function validateAutomaticAdvisorFlip( (source.producer === 'guildTransport' && Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,territory,transport,turn') || (source.producer === 'gamont' && - Object.keys(source).sort().join(',') === 'event,forces,gamont,owner,phase,producer,territory,turn')) && + 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')) && identifier(source.event) && identifier(source.owner) && identifier(source.territory) && positive(source.turn) && source.turn === ctx.turn && - source.phase === (source.producer === 'gamont' ? 8 : 5) && + source.phase === (source.producer === 'gamont' ? 8 : source.producer === 'battleLosses' ? 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); @@ -228,7 +277,8 @@ export function validateAutomaticAdvisorFlip( requireFlip(movers.length === 1 && positive(source.move) && movers[0].moved === source.move, 'Automatic advisor conversion lost its original completed movement.'); } else if (source.producer === 'guildTransport') validateTransportSource(ctx, source); - else validateGamontSource(ctx, source); + else if (source.producer === 'gamont') validateGamontSource(ctx, source); + else validateBattleSource(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 facfc57c..1232e0e1 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 } from './advisor-automatic-flip'; +import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle } 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'; @@ -1265,6 +1265,11 @@ export type Game = { pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[]; pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport; pendingAutomaticAdvisorGamont?: { receipt: AutomaticAdvisorGamont; card: string; nexusEvent?: string }; + pendingAutomaticAdvisorBattle?: { + receipt: AutomaticAdvisorBattle; + resume: { kind: 'winner'; player: string; territory: string; cards: string[] } | { kind: 'aftermath' } | null; + signature?: string; + }; advisorPreparation?: AdvisorPreparationWindow; ecazOccupyPreview?: boolean; /** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */ @@ -20292,13 +20297,19 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) { response.owner === source.owner && response.location === source.territory && response.advisorResume === undefined && JSON.stringify(g.pendingAutomaticAdvisorFlips?.[0]) === JSON.stringify(source), - 'The mandatory advisor conversion has lost its original movement source.'); + 'The mandatory advisor conversion has lost its original committed source.'); if (source.producer === 'guildTransport') requireRule(JSON.stringify(source.transport) === JSON.stringify(g.pendingAutomaticAdvisorTransport), 'The mandatory conversion has lost its original committed Guild transport.'); if (source.producer === 'gamont') requireRule(JSON.stringify(source.gamont) === JSON.stringify(g.pendingAutomaticAdvisorGamont?.receipt), 'The mandatory conversion has lost its original completed Trip to Gamont.'); + if (source.producer === 'battleLosses') { + const parent = g.pendingAutomaticAdvisorBattle; + requireRule(parent && JSON.stringify(source.battle) === JSON.stringify(parent.receipt), + 'The mandatory conversion has lost its original completed battle.'); + validateBattleAdvisorResume(g, parent); + } automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source)); return source; } @@ -20306,6 +20317,8 @@ function openAutomaticAdvisorFlip(g: Game) { if (g.response || g.decision || g.truthtrance || g.phaseOpening) return; const source = g.pendingAutomaticAdvisorFlips?.[0]; if (!source) return; + // Hold immediate losses through the original mandatory discard/casualty work. + if (source.producer === 'battleLosses' && !g.pendingAutomaticAdvisorBattle?.resume) return; automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source)); g.response = { kind: 'advisorFlip', owner: source.owner, location: source.territory, advisors: false, passed: [], automaticAdvisorFlip: source, @@ -20381,6 +20394,73 @@ function finishAutomaticAdvisorGamont(g: Game) { if (parent.receipt.mentat) g.decision = { kind: 'choamMentat', player: parent.receipt.player }; } +/** Capture actual surviving bystanders before settlement can auto-convert them. */ +function prepareBattleAdvisorFlips(g: Game) { + const context = g.lastBattleContext; + if (!g.advanced || g.status !== 'playing' || g.phase !== 6 || g.battle || + !context || context.turn !== g.turn || context.result === 'legacy') return; + if (g.pendingAutomaticAdvisorBattle) { + requireRule(g.pendingAutomaticAdvisorBattle.receipt.event === context.event, + 'Finish the original battle conversion before another battle.'); + return; + } + const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g)) + .filter(group => group.territory === context.territory && !context.combatants.includes(group.player)); + if (!groups.length) return; + requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport && + !g.pendingAutomaticAdvisorGamont, 'Finish the original advisor conversion first.'); + const receipt: AutomaticAdvisorBattle = { event: context.event, turn: context.turn, + territory: context.territory, combatants: [...context.combatants], + winner: context.winner, result: context.result }; + // Private cleanup cards and cursor never enter the public source receipt. + g.pendingAutomaticAdvisorBattle = { receipt, resume: null }; + 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: 'battleLosses', battle: receipt }; + getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event; + (g.pendingAutomaticAdvisorFlips ??= []).push(source); + } +} +function validateBattleAdvisorResume(g: Game, parent: NonNullable) { + const resume = parent.resume; + requireRule(g.phase === 6 && !g.battle && parent.receipt.event === g.lastBattleContext?.event && + resume && parent.signature === JSON.stringify(resume) && !g.pendingSukRescue && + !g.pendingWinnerDiscards && !g.pendingTreacheryDiscard, + 'Finish the original mandatory battle costs and casualties before advisor conversion.'); + if (resume.kind === 'winner') { + const winner = getPlayer(g, resume.player); + requireRule(parent.receipt.winner === winner.id && resume.territory === parent.receipt.territory && + new Set(resume.cards).size === resume.cards.length && + resume.cards.every(id => winner.hand.some(card => card.id === id)), + 'The advisor conversion lost the original winner cleanup cards.'); + } else requireRule(resume.kind === 'aftermath', 'Resume only the original battle aftermath.'); +} +/** Pause only at the original cleanup cursor; never rerun battle resolution. */ +function openBattleAdvisorFlips(g: Game, resume: NonNullable['resume']>) { + prepareBattleAdvisorFlips(g); + const parent = g.pendingAutomaticAdvisorBattle; + if (!parent) return false; + requireRule(parent.resume === null && !!g.pendingAutomaticAdvisorFlips?.length && + !g.response && !g.decision, 'The original battle conversion already owns its cleanup.'); + parent.resume = structuredClone(resume); + parent.signature = JSON.stringify(parent.resume); + validateBattleAdvisorResume(g, parent); + openAutomaticAdvisorFlip(g); + return true; +} +function finishAutomaticAdvisorBattle(g: Game) { + const parent = g.pendingAutomaticAdvisorBattle; + if (!parent) return; + requireRule(!g.pendingAutomaticAdvisorFlips?.length, + 'Finish all original advisor conversions before battle cleanup.'); + validateBattleAdvisorResume(g, parent); + const resume = parent.resume!; + delete g.pendingAutomaticAdvisorBattle; + if (resume.kind === 'winner') + finishWinner(g, getPlayer(g, resume.player), resume.territory, resume.cards); + else finishBattle(g); +} function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) { for (const entry of released) delete getPlayer(g, entry.player).advisors?.[entry.territory]; @@ -22400,7 +22480,8 @@ function decideSharedSpice(g: Game, id: string, action: Action) { function victory(g: Game, quote?: VictoryQuote) { requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport && - !g.pendingAutomaticAdvisorGamont, 'Resolve mandatory advisor conversions before checking victory.'); + !g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle, + 'Resolve mandatory advisor conversions before checking victory.'); quote ??= currentVictoryQuote(g); applyAdvisorReleases(g, quote.released); g.winner = quote.winner; @@ -25673,6 +25754,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { battleMandatoryDiscardSignature(g.lastBattleContext); } g.battle = null; + prepareBattleAdvisorFlips(g); const cards = [...quote.winnerCards]; const continuation: Extract< NonNullable['continuation'], @@ -26149,6 +26231,7 @@ function projectedRihani(g: Game, owner: string) { } : null }; } function finishWinner(g: Game, winner: Player, t: string, cards: string[]) { + prepareBattleAdvisorFlips(g); if (startRihaniVictory(g, winner, t, cards)) return; const assassination = g.moritaniAssassinate?.opportunities.find(r => r.stage === 'skills'); if (assassination) { @@ -26192,6 +26275,7 @@ function finishWinner(g: Game, winner: Player, t: string, cards: string[]) { territory: t, optional: [...cards], commitment: structuredClone(pending) }); return; } + if (openBattleAdvisorFlips(g, { kind: 'winner', player: winner.id, territory: t, cards })) return; const context = g.lastBattleContext; const moritani = byFaction(g, 'moritani'); if (g.advanced && context && moritani && !moritani.specialKaramaUsed && @@ -26411,6 +26495,7 @@ function projectedHomeworldVictoryReturn(g: Game, player: string) { }; } function finishBattle(g: Game) { + if (openBattleAdvisorFlips(g, { kind: 'aftermath' })) return; const quote = currentBattleAftermathQuote(g); for (const step of quote.steps) { if (step.kind === 'transferTech') { @@ -28217,13 +28302,14 @@ 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' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use 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' ? '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.`, { faction: bg.faction, name: 'Mandatory advisor conversion prevented' }); g.pendingAutomaticAdvisorFlips!.shift(); if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips; if (!g.pendingAutomaticAdvisorFlips?.length) { finishAutomaticAdvisorTransport(g); finishAutomaticAdvisorGamont(g); + finishAutomaticAdvisorBattle(g); } else openAutomaticAdvisorFlip(g); } else finishAdvisorReaction(g, response); } diff --git a/game/karama-context.ts b/game/karama-context.ts index 5cba6f56..1b613440 100644 --- a/game/karama-context.ts +++ b/game/karama-context.ts @@ -183,6 +183,7 @@ const cancellationSources = { ? { source: response.automaticAdvisorFlip, pending: g.pendingAutomaticAdvisorFlips?.[0] ?? null, transport: response.automaticAdvisorFlip.producer === 'guildTransport' ? g.pendingAutomaticAdvisorTransport ?? null : null, gamont: response.automaticAdvisorFlip.producer === 'gamont' ? g.pendingAutomaticAdvisorGamont?.receipt ?? null : null, + battle: response.automaticAdvisorFlip.producer === 'battleLosses' ? g.pendingAutomaticAdvisorBattle?.receipt ?? 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 8c648917..4c6654c6 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4315,6 +4315,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A normal shipment enters as fighters unless joining existing advisors. Moving or shipping into your existing group must match its type. Advisors moved into an occupied territory without another Bene Gesserit group may remain peaceful or request a flip to fighters.', 'After Trip to Gamont completes during Mentat, the last outsider’s return or empty No-Field reveal can leave advisors alone. Karama can then prevent their conversion without undoing the completed effect or card use. The final CHOAM opportunity resumes afterward; victory waits for its original confirmation. Ordinary Mentat does not gain an extra final chooser.', '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.', '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.', @@ -4326,7 +4327,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Implementation", "status": "Partial", - "detail": "Gamont now commits cost/return before phase8 conversion, keeps private fuel provenance out of its public event, and defers the original final chooser until the parent is consumed once. Victory waits for original confirmation; ordinary Mentat gains no chooser. Earlier ground/transport/preparation: Committed Guild cross/return now binds an exact public transport parent, holds the affected group before board benefits, preserves payment/shipment use, and resumes arrival once after conversion. Private projections/from-reserves do not create occurrences; legacy ground receipt shape remains. Earlier ground/preparation: Ground mandatory conversion is now prototyped: physical advisor arrival/last-outsider departure commits before an exact sourced Karama response. Shared normalization/board quotes retain a prevented continuously-alone stance. Non-ground producers and matching-group conversion remain unfinished. Earlier preparation: Shared native public-board eligibility wraps a current-turn receipt and exact owner/territory/turn source. Direct preparation resolves before-cost and outcome validation without entering movement from earlier phases. Native future declaration queues remain intact; successful shipment commits close the receipt, including markers and positive Sneak Attack.", + "detail": "Ground movement, committed Guild transport, Gamont and actual battle losses now have distinct event-bound mandatory-conversion sources. Shared settlement preserves prevented continuously-alone groups. Battle receipts select public identity/outcome only; a private cursor resumes original winner/aftermath after mandatory disposal, skills and substitution without replaying casualties. Transport retains paid transfer and one arrival; Gamont defers the original final chooser. Phases2–5 preparation retains its own window and per-territory prevention. Other automatic producers and matching-group counters remain unfinished.", "evidence": [ "game/advisor-automatic-flip.ts", "game/advisors.ts", @@ -4341,7 +4342,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Player controls", "status": "Partial", - "detail": "Standard Gamont conversion response explains completed effect/card use and the pending victory check. Earlier controls: Transport conversion uses original Karama/Allow controls with completed-payment guidance; later real Intrusion remains its own decision. Earlier controls: Standard Karama/Allow responses show the completed ground move and explicit provisional continuous-alone duration, with no new confirmation or timer. Earlier preparation controls: Persistent owner-only territory actions and blocked/provisional guidance work in phases2–5 at quiet boundaries, including after initial decline. Busy and mandatory interactions stay blocked; private counts/cards/balances are not read. Original phase5 chooser remains.", + "detail": "Standard Karama/Allow controls explain completed movement, paid transport, Gamont card use or battle losses and the provisional continuously-alone hold. Battle guidance distinguishes committed casualties from remaining cleanup. Existing preparation controls remain available at quiet phases2–5 boundaries, including after initial decline; first actual shipment closes that window. No new confirmation or timing delay.", "evidence": [ "docs/ADVISOR_AUTOMATIC_FLIPS.md", "components/advisor-preparation.tsx", @@ -4352,8 +4353,10 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "AI", "status": "Partial", - "detail": "Eight existing-profile ordinary/final Gamont paths resolve legally before the actual final outcome, without strategy changes. Earlier paths: Eight existing-profile transport response paths stay legal without consuming ordinary movement or changing strategy. Earlier paths: Eight existing-profile ground-conversion paths accept legal standard responses and continue without a new strategy. Earlier preparation: The existing native preparation preference consumes the projected window; sixteen own-view existing-profile response paths accept16 legal proposals. No strategy tuning or difficulty calibration is claimed.", + "detail": "Eight existing-profile native winner-dial-all/mutual-Traitor response paths resolve legally to actual Collection without replay. Existing ground, transport, Gamont and preparation paths retain their separate evidence. No strategy refinement or difficulty calibration.", "evidence": [ + "tests/advisor-battle-flip-engine.test.ts", + "tests/fixture-advisor-battle-flip.ts", "tests/advisor-gamont-flip-engine.test.ts", "tests/advisor-transport-flip-engine.test.ts", "tests/advisor-automatic-flip-engine.test.ts", @@ -4364,7 +4367,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Documentation", "status": "Partial", - "detail": "Gamont scope distinguishes original-effect prevention, empty revelation, private fuel, final/ordinary timing and the unchanged provisional hold. Earlier contracts: Guild transport source/slot/payment and exactly-once arrival are distinguished from permission/rate; continuously-alone duration remains provisional. Earlier guidance: The ground-conversion contract distinguishes the printed counter, automatic setup, provisional continuous-alone duration and remaining producers. Earlier preparation: Supplied physical28 G and publisher timing distinguish the open window from shipment allowance flags. Denied-territory scope is provisional, existing Ecaz co-side composition is retained, and old pending/unknown-clock behavior and remaining forced-flip/combination limits are explicit.", + "detail": "Source physical17/28 E composes the group counter with mandatory conversion. Continuously-alone duration and battle cleanup priority are explicitly provisional. Public receipts, private continuation boundaries, native scenario provenance and remaining producers/combinations are distinguished. Preparation and supported Ecaz coexistence keep their separate contracts.", "evidence": [ "docs/ADVISOR_AUTOMATIC_FLIPS.md", "docs/ADVISOR_PREPARATION.md", @@ -4375,8 +4378,10 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Verification", "status": "Partial", - "detail": "Gamont passes frozen18-file277/277, types/lint/build, five native Gamont/response/GameTable SSR journeys and eight profile paths. Genuine52/56-action sources earn two holds and a real final win versus next-turn outcome; empty revelation is pure-source evidence. Full No-Field/Nexus/Semuta and remaining producers remain open. Earlier proof: Transport passes frozen17-file243/243, types/lint/build, eight native transport/response/GameTable SSR journeys and eight existing-profile paths. Genuine36/42-action sources earn original Tueks5 and actual paid destination Intrusion. Affordable full-price return and unfunded cross remain distinct. Broader producers and final assurance stay open. Earlier proof: Ground conversion passes frozen16-file199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Genuine34/36-action sources retain originals; standard controls render without new callback/browser/deployed certification. Earlier preparation evidence: 16 files229/229, types/lint/build, twelve genuine new-control/gameplay/GameTable SSR journeys and sixteen minimal response paths pass. Public23/29/32/38/39-action sources preserve original setup advisor/Karama; a separate controlled ground/Terror unit proves positive Sneak Attack closure and zero nonclosure. Full combinations, visual/browser/deployed and comprehensive assurance remain open.", + "detail": "Battle conversion passes frozen16-file264/264, types/lint/build, five native battle/response/GameTable SSR journeys and eight existing-profile paths. Genuine44-action public sources earn original forces/cards and actual two-versus-zero city income; Lasgun/Shield destroys the advisor without a counter. Stale Semuta fixtures now submit existing final acknowledgements; production guards stay unchanged. Earlier ground/transport/Gamont/preparation evidence is in linked contracts. Full skill/Caladan/Face Dance/Semuta combinations, remaining producers, complete modes, browser/mobile/deployed and comprehensive assurance stay open.", "evidence": [ + "tests/advisor-battle-flip-engine.test.ts", + "tests/fixture-advisor-battle-flip.ts", "tests/advisor-gamont-flip-engine.test.ts", "tests/fixture-advisor-gamont-flip.ts", "tests/advisor-transport-flip-engine.test.ts", @@ -4391,7 +4396,12 @@ export const RULE_TOPICS: RuleTopic[] = [ "tests/moritani-force-leader.test.ts", "tests/advisors.test.ts", "tests/movement-phase-quote.test.ts", - "tests/karama-movement-cancellation.test.ts" + "tests/karama-movement-cancellation.test.ts", + "tests/fixture-semuta-battle-karama.ts", + "tests/fixture-semuta-homeworld-battle-loser.ts", + "tests/fixture-semuta-homeworld-battle-winner.ts", + "tests/fixture-semuta-homeworld-choam.ts", + "tests/semuta-homeworld-battle-winner.test.ts" ] } ], diff --git a/tests/advisor-automatic-flip-quote.test.ts b/tests/advisor-automatic-flip-quote.test.ts index ad19eca2..f7a2a9e1 100644 --- a/tests/advisor-automatic-flip-quote.test.ts +++ b/tests/advisor-automatic-flip-quote.test.ts @@ -10,12 +10,14 @@ import { type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, + type AutomaticAdvisorBattle, } from '../game/advisor-automatic-flip'; import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board'; type GroundSource = Extract; type TransportSource = Extract; type GamontSource = Extract; +type BattleSource = Extract; function context(): AutomaticAdvisorFlipContext { return { @@ -66,7 +68,7 @@ void test('quotes exclude held, occupied and physically empty advisor groups whi ctx.players[1].forces = { ...ctx.players[1].forces, 'arrakeen:10': 1 }; assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []); for (const patch of [ - { advanced: false }, { status: 'lobby' }, { phase: 0 }, { phase: 4 }, { phase: 6 }, + { advanced: false }, { status: 'lobby' }, { phase: 0 }, { phase: 4 }, { phase: 7 }, ]) { assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []); } @@ -482,3 +484,151 @@ void test('post-Gamont validation does not ask for prior availability, a remaini assert.deepEqual(ctx.players[2].forces, {}); assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); }); + +function battleResult(result: AutomaticAdvisorBattle['result'] = 'normal') { + const { ctx, source: ground } = held(); + ctx.phase = 6; + ctx.battle = null; + ctx.players[1].forces = {}; + ctx.players[2].forces = {}; + const receipt: AutomaticAdvisorBattle = { + event: 'original-resolved-battle', turn: ctx.turn, territory: 'imperial_basin', + combatants: ['a', 'r'], winner: result === 'normal' || result === 'traitor' ? 'a' : null, result, + }; + ctx.lastBattleContext = { ...receipt, combatants: [...receipt.combatants] }; + const source: BattleSource = { + event: 'automatic-after-original-battle-losses', owner: ground.owner, + territory: ground.territory, turn: ground.turn, phase: 6, + forces: ground.forces, producer: 'battleLosses', battle: receipt, + }; + ctx.players[0].advisors = { + ...ctx.players[0].advisors, + imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event }, + }; + return { ctx, source }; +} + +void test('phase-six quotes and exact public resolved battle receipts retain a physically nonempty BG bystander group', () => { + const quiet = context(); + quiet.phase = 6; + const original = structuredClone(quiet); + assert.equal(quoteAutomaticAdvisorFlips(quiet).some((entry) => entry.territory === 'imperial_basin'), true); + assert.deepEqual(quiet, original); + for (const result of ['normal', 'traitor', 'mutualTraitors'] as const) { + const { ctx, source } = battleResult(result); + const before = structuredClone({ ctx, source }); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); + assert.deepEqual({ ctx, source }, before); + assert.deepEqual(Object.keys(source).sort(), ['battle', 'event', 'forces', 'owner', 'phase', 'producer', 'territory', 'turn']); + assert.deepEqual(Object.keys(source.battle).sort(), ['combatants', 'event', 'result', 'territory', 'turn', 'winner']); + } +}); + +void test('a stale battle event, turn, winner, participant, location or active battle cannot settle the held source', () => { + const changes: ((ctx: AutomaticAdvisorFlipContext, source: BattleSource) => void)[] = [ + (ctx) => { ctx.advanced = false; }, + (ctx) => { ctx.status = 'lobby'; }, + (ctx) => { ctx.turn++; }, + (ctx) => { ctx.battle = {}; }, + (ctx) => { ctx.lastBattleContext = null; }, + (ctx) => { delete ctx.lastBattleContext; }, + (ctx) => { ctx.lastBattleContext!.event = 'changed-battle'; }, + (ctx) => { ctx.lastBattleContext!.turn++; }, + (ctx) => { ctx.lastBattleContext!.territory = 'arrakeen'; }, + (ctx) => { ctx.lastBattleContext!.winner = 'r'; }, + (ctx) => { ctx.lastBattleContext!.result = 'traitor'; }, + (_ctx, source) => { source.battle.event = 'stale-battle'; }, + (_ctx, source) => { source.battle.turn++; }, + (_ctx, source) => { source.battle.territory = 'arrakeen'; }, + (_ctx, source) => { source.battle.territory = 'missing'; }, + (_ctx, source) => { source.battle.combatants = ['a', 'a']; }, + (_ctx, source) => { source.battle.combatants = ['a', 'missing']; }, + (_ctx, source) => { source.battle.combatants.reverse(); }, + (_ctx, source) => { source.battle.winner = null; }, + (_ctx, source) => { source.battle.winner = 'b'; }, + (_ctx, source) => { source.battle.result = 'mutualTraitors'; }, + (_ctx, source) => { source.battle.result = 'explosion'; }, + (ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; }, + (ctx) => { ctx.players[1].forces = { 'imperial_basin:9': 1 }; }, + (_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; }, + ]; + for (const change of changes) { + const { ctx, source } = battleResult(); + change(ctx, source); + const before = structuredClone({ ctx, source }); + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); + assert.deepEqual({ ctx, source }, before); + } + const { ctx, source } = battleResult(); + for (const malformed of [ + { ...source, battle: null }, + { ...source, battle: { ...source.battle, result: 'legacy' } }, + { ...source, battle: { ...source.battle, winner: 1 } }, + { ...source, battle: { ...source.battle, combatants: ['a'] } }, + { ...source, battle: { ...source.battle, combatants: ['a', 'r', 'b'] } }, + ]) { + assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError); + } +}); + +void test('a BG combatant or an explosion with no remaining physical advisor group has no valid bystander conversion', () => { + const combatant = battleResult(); + combatant.source.battle.combatants = ['b', 'r']; + combatant.source.battle.winner = 'r'; + combatant.ctx.lastBattleContext = { ...combatant.source.battle, combatants: ['b', 'r'] }; + assert.throws(() => validateAutomaticAdvisorFlip(combatant.ctx, combatant.source), AutomaticAdvisorFlipError); + const exploded = battleResult('explosion'); + assert.equal(exploded.source.battle.winner, null); + for (const player of exploded.ctx.players) player.forces = {}; + assert.deepEqual(quoteAutomaticAdvisorFlips(exploded.ctx), []); + assert.throws(() => validateAutomaticAdvisorFlip(exploded.ctx, exploded.source), AutomaticAdvisorFlipError); +}); + +void test('battle source and receipt layers reject private cards, skills, casualty choices and continuation payloads', () => { + const { ctx, source } = battleResult(); + for (const field of ['cards', 'skills', 'casualtyChoices', 'continuation']) { + for (const malformed of [ + { ...source, [field]: 'private-payload' }, + { ...source, battle: { ...source.battle, [field]: 'private-payload' } }, + ]) { + assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError); + } + } +}); + +void test('phase-six battle conversion does not widen the old movement, transport or Gamont producer windows', () => { + for (const original of [held(), transported(), gamont(false)]) { + const before = JSON.stringify(original.source); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source)); + assert.equal(JSON.stringify(original.source), before); + original.ctx.phase = 6; + original.source.phase = 6; + assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError); + } + for (const phase of [5, 8]) { + const { ctx, source } = battleResult(); + ctx.phase = phase; + source.phase = phase; + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); + } +}); + +void test('battle validation reads only its six public context fields and the current physical group', () => { + const { ctx, source } = battleResult(); + for (const player of ctx.players) { + for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'moved', 'shipped', 'reserves']) { + Object.defineProperty(player, field, { + get() { throw new Error(`Battle advisor rule read ${player.id} private or unrelated ${field}`); }, + }); + } + } + for (const field of ['cardRoles', 'mandatoryDiscard', 'winnerDiscards', 'skills', 'casualtyChoices', 'continuation']) { + Object.defineProperty(ctx.lastBattleContext, field, { + enumerable: true, + get() { throw new Error(`Battle advisor rule read private context ${field}`); }, + }); + } + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); + ctx.battle = false; + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); +}); diff --git a/tests/advisor-battle-flip-engine.test.ts b/tests/advisor-battle-flip-engine.test.ts new file mode 100644 index 00000000..e7168c8b --- /dev/null +++ b/tests/advisor-battle-flip-engine.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; +import { fighterCount, isAdvisor } from '../game/advisors'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + createAdvisorBattleFlipFixture as fixture, actAdvisorBattleFlip as act, + commitAdvisorBattleFlip as commit, finishAdvisorBattleFlip as finish, + advisorBattleFlipPlayer as player, advisorBattleFlipInventory as inventory, + advisorBattleFlipTraitors as traitors, +} from './fixture-advisor-battle-flip'; + +function allow(source: Game) { + let game = source; + for (let count = 0; game.response?.automaticAdvisorFlip && count < 30; count++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + game = act(game, actor.id, { type: 'passResponse' }); + } + assert.ok(!game.response?.automaticAdvisorFlip); return game; +} +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 mutualTraitors of [false, true]) for (const canceled of [false, true]) { + void test(`native ${mutualTraitors ? 'mutual traitors' : 'winner dial-all'} ${canceled ? 'prevention' : 'allowance'} preserves committed losses and resumes original cleanup once`, () => { + const f = fixture({ mutualTraitors }), original = structuredClone(f.game); + const pending = commit(f.game, f).game; + assert.deepEqual(f.game, original); + assert.equal(pending.phase, 6); assert.equal(pending.battle, null); + assert.equal(pending.response?.kind, 'advisorFlip'); + const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'battleLosses'); + assert.equal(source.battle.event, f.event); + assert.equal(source.battle.result, mutualTraitors ? 'mutualTraitors' : 'normal'); + assert.equal(source.battle.winner, mutualTraitors ? null : f.atreides); + assert.deepEqual(Object.keys(source.battle).sort(), ['combatants', 'event', 'result', 'territory', 'turn', 'winner']); + assert.equal(pending.pendingTreacheryDiscard == null, true); + assert.equal(pending.pendingWinnerDiscards == null, true); + assert.equal(pending.decision, null); + assert.equal(player(pending, f.atreides).forces[f.location], undefined); + assert.equal(player(pending, f.harkonnen).forces[f.location], undefined); + assert.equal(player(pending, f.atreides).tanks, player(original, f.atreides).tanks + 1); + assert.equal(player(pending, f.harkonnen).tanks, player(original, f.harkonnen).tanks + 1); + assert.equal(player(pending, f.bg).forces[f.location], 1); + assert.equal(fighterCount(player(pending, f.bg), f.territory), 0); + assert.equal(pending.discard.filter(c => c.id === f.shield).length, mutualTraitors ? 1 : 0); + assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama)); + assert.ok(!JSON.stringify(viewGame(pending, f.holder).response).includes('resume')); + const parent = pending.pendingAutomaticAdvisorBattle!; + assert.equal(parent.resume?.kind, mutualTraitors ? 'aftermath' : 'winner'); + if (parent.resume?.kind === 'winner') assert.deepEqual(parent.resume.cards, [f.shield]); + rejected(pending, f.atreides, { type: 'decision', discard: [] }); + for (let read = 0; read < 3; read++) { + const stable = normalizeAutomaticGame(pending); + assert.deepEqual(stable.pendingAutomaticAdvisorBattle, parent); + assert.deepEqual(physical(stable), physical(pending)); + assert.equal(isAdvisor(player(stable, f.bg), f.territory), true); + } + const committed = physical(pending), bgSpice = player(pending, f.bg).spice; + const done = canceled + ? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }) + : allow(pending); + assert.equal(done.pendingAutomaticAdvisorBattle, undefined); + assert.equal(done.pendingAutomaticAdvisorFlips, undefined); + assert.deepEqual(physical(done), committed); + assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled); + if (!mutualTraitors) { + assert.deepEqual(done.decision, { kind: 'battleCards', player: f.atreides, + territory: f.territory, cards: [f.shield] }); + assert.ok(player(done, f.atreides).hand.some(c => c.id === f.shield)); + } + const collected = finish(done); + assert.equal(collected.phase, 7); + assert.equal(player(collected, f.bg).spice, bgSpice + (canceled ? 0 : 2)); + assert.deepEqual(physical(collected), committed); + assert.equal(collected.discard.filter(c => c.id === f.shield).length, mutualTraitors ? 1 : 0); + assert.equal(collected.discard.filter(c => c.id === f.karama).length, canceled ? 1 : 0); + assert.deepEqual(inventory(collected), f.originalInventory); + assert.deepEqual(traitors(collected), f.originalTraitors); + assert.equal(isAdvisor(player(normalizeAutomaticGame(collected), f.bg), f.territory), canceled); + }); +} + +void test('changing the original public battle parent or private cleanup cursor rejects before spending Karama', () => { + const f = fixture(), pending = commit(f.game, f).game; + for (const kind of ['receipt', 'cursor'] as const) { + const invalid = structuredClone(pending); + if (kind === 'receipt') invalid.pendingAutomaticAdvisorBattle!.receipt.event = 'different-battle'; + else invalid.pendingAutomaticAdvisorBattle!.resume = { kind: 'aftermath' }; + rejected(invalid, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }); + assert.ok(player(invalid, f.holder).hand.some(c => c.id === f.karama)); + assert.equal(player(invalid, f.atreides).tanks, player(pending, f.atreides).tanks); + } +}); + +void test('a native Lasgun Shield explosion destroys the bystanding advisors without inventing a conversion', () => { + const f = fixture({ explosion: true }), done = commit(f.game, f).game; + assert.equal(done.lastBattleContext?.result, 'explosion'); + assert.equal(done.lastBattleContext?.winner, null); + assert.equal(done.pendingAutomaticAdvisorBattle, undefined); + assert.equal(done.pendingAutomaticAdvisorFlips, undefined); + assert.equal(done.response?.automaticAdvisorFlip, undefined); + assert.equal(player(done, f.bg).forces[f.location], undefined); + assert.equal(player(done, f.bg).tanks, player(f.game, f.bg).tanks + 1); + const collected = finish(done); + assert.equal(player(collected, f.bg).spice, player(f.game, f.bg).spice); + assert.ok(player(collected, f.holder).hand.some(c => c.id === f.karama)); + assert.equal(collected.discard.filter(c => c.id === f.shield).length, 1); + assert.equal(collected.discard.filter(c => c.id === f.extra).length, 1); + assert.deepEqual(inventory(collected), f.originalInventory); +}); + +void test('the four existing profiles resolve both committed battle conversion responses legally without replay', () => { + for (const mutualTraitors of [false, true]) for (const profile of DIFFICULTIES) { + const f = fixture({ mutualTraitors }), pending = commit(f.game, f).game; + const 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.pendingAutomaticAdvisorBattle, undefined); + assert.equal(done.phase, 7); + assert.deepEqual(physical(done), physical(pending)); + assert.deepEqual(inventory(done), f.originalInventory); + } +}); diff --git a/tests/fixture-advisor-battle-flip.ts b/tests/fixture-advisor-battle-flip.ts new file mode 100644 index 00000000..d02b6042 --- /dev/null +++ b/tests/fixture-advisor-battle-flip.ts @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { + actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory, + advisorPreparationPlayer, nextAdvisorPreparationStep, +} from './fixture-advisor-preparation'; + +export type AdvisorBattleFlipStep = { actor: string; action: Action }; +export type AdvisorBattleFlipTransition = { before: Game; steps: AdvisorBattleFlipStep[]; after: Game }; +export const actAdvisorBattleFlip = actAdvisorPreparation; +export const advisorBattleFlipClean = advisorPreparationClean; +export const advisorBattleFlipInventory = advisorPreparationInventory; +export const advisorBattleFlipPlayer = advisorPreparationPlayer; +export const advisorBattleFlipTraitors = (game: Game) => { + const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => + [...player.traitors, ...player.traitorChoices])]; + assert.equal(new Set(identities).size, identities.length, 'Each original physical Traitor has one current slot'); + return identities.sort(); +}; + +/** Native continuation preserves both caller-owned conversion and winner-card + * choices. No new advisor counter or optional winner discard is passed here. */ +export function nextAdvisorBattleFlip(game: Game): AdvisorBattleFlipStep | null { + if (game.response?.automaticAdvisorFlip) return null; + if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) { + if (game.decision?.kind === 'battleCards') return null; + if (game.decision?.kind === 'advisorBattle') + return { actor: game.decision.player, action: { type: 'decision', accept: false } }; + if (game.status === 'setup' && game.setupStage === 'traitors') { + const player = game.players.find(candidate => candidate.traitorChoices.length); assert.ok(player); + const leader = player.faction === 'atreides' && player.traitorChoices.includes('harkonnen-3') + ? 'harkonnen-3' : player.traitorChoices[0]; + return { actor: player.id, action: { type: 'traitor', leader } }; + } + } + return nextAdvisorPreparationStep(game); +} +export function advanceAdvisorBattleFlip(game: Game, until: (state: Game) => boolean, + steps: AdvisorBattleFlipStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The genuine battle chronology ended before its requested boundary'); + const next = nextAdvisorBattleFlip(game); + assert.ok(next, `Choose the original private plan, conversion or winner cards at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actAdvisorBattleFlip(game, next.actor, next.action, steps); + } + throw Error('The native advisor battle source did not reach its finite boundary'); +} +/** Pass ordinary original responses only; the mandatory conversion stays owned. */ +export function passAdvisorBattleFlip(game: Game, steps: AdvisorBattleFlipStep[] = []): 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 = actAdvisorBattleFlip(game, actor.id, { type: 'passResponse' }, steps); + } + assert.ok(!game.response || game.response.automaticAdvisorFlip); return game; +} + +/** Compute only original Fisher-Yates scalar draws, before any Traitor is + * dealt. The actual native prediction action performs the resulting shuffle. */ +function traitorPermutation(original: string[], desired: string[]) { + assert.deepEqual([...original].sort(), [...desired].sort()); + const current = [...original], scalars: number[] = []; + for (let index = current.length - 1; index > 0; index--) { + const from = current.indexOf(desired[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, desired); return scalars; +} +function predictWithOriginalTraitors(game: Game, actor: string, action: Action, + scalars: number[], steps: AdvisorBattleFlipStep[]): Game { + assert.equal(game.status, 'setup'); assert.equal(game.setupStage, 'prediction'); + assert.ok(game.players.every(player => !player.traitors.length && !player.traitorChoices.length)); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = 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 original Traitor shuffle consumes selected prediction entropy'); + array[0] = scalars[cursor++]; + } else Reflect.apply(original, crypto, [array]); + return array; + }; + try { + const next = applyAction(game, actor, action); + assert.equal(cursor, scalars.length, 'The actual original Traitor shuffle uses its complete selected permutation'); + steps.push({ actor, action: structuredClone(action) }); return next; + } finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Actual public Advanced BG/Atreides/Harkonnen base33. Atreides leaves one + * counter beside the original advisor; Harkonnen moves one native counter in. + * Stop immediately after choosing that real battle, before preparation/plans. */ +export function createAdvisorBattleFlipFixture(options: { mutualTraitors?: boolean; explosion?: boolean } = {}) { + const bg = 'beneGesserit', atreides = 'atreides', harkonnen = 'harkonnen'; + const mutualTraitors = options.mutualTraitors ?? false; + const explosion = options.explosion ?? false; + assert.ok(!mutualTraitors || !explosion, 'Select one original battle outcome'); + let game = createGame('ADVISORBATTLEFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true); + joinGame(game, newPlayer(atreides, 'Atreides', 'atreides')); + joinGame(game, newPlayer(harkonnen, 'Harkonnen', 'harkonnen')); + const initial = structuredClone(game), steps: AdvisorBattleFlipStep[] = []; + const act = (actor: string, action: Action) => { game = actAdvisorBattleFlip(game, actor, action, steps); }; + const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorBattleFlip(game, until, steps); }; + const transition = (run: () => void): AdvisorBattleFlipTransition => { + 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.equal(game.setupStage, 'prediction'); + assert.deepEqual(game.expansions, []); + 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.traitorChoices.length)); + const originalInventory = advisorBattleFlipInventory(game); assert.equal(originalInventory.length, 33); + const karama = game.deck.find(card => card.effect === 'karama'); + const shield = game.deck.find(card => card.kind === 'shield'); + const ordinary = game.deck.find(card => card.kind === 'projectile'); + const extra = game.deck.find(card => card.kind === (explosion ? 'lasgun' : 'poison')); assert.ok(karama && shield && ordinary && extra); + const selected = [ordinary, shield, karama, extra]; + 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 beforeTraitorDeal = structuredClone(game); + const originalTraitors = game.players.flatMap(player => player.leaders.map(leader => leader.id)).sort(); + const traitorOrder = game.players.flatMap(player => player.leaders.map(leader => leader.id)); + if (mutualTraitors) { + const iakin = traitorOrder.indexOf('harkonnen-3'), thufir = traitorOrder.indexOf('atreides-0'); + assert.ok(iakin >= 0 && thufir >= 0); + [traitorOrder[4], traitorOrder[iakin]] = [traitorOrder[iakin], traitorOrder[4]]; + [traitorOrder[8], traitorOrder[thufir]] = [traitorOrder[thufir], traitorOrder[8]]; + } + const traitorScalars = traitorPermutation(game.players.flatMap(player => player.leaders.map(leader => leader.id)), traitorOrder); + const traitorDeal = transition(() => { + game = predictWithOriginalTraitors(game, bg, { type: 'predict', faction: 'atreides', turn: 10 }, traitorScalars, steps); + }); + const beforeStartingDeal = structuredClone(game); + let advisorSetup: AdvisorBattleFlipTransition | null = null; + for (let count = 0; game.status === 'setup' && count < 100; count++) { + const next = nextAdvisorBattleFlip(game); assert.ok(next); + const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null; + act(next.actor, next.action); + if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) }; + } + assert.equal(game.status, 'playing'); assert.ok(advisorSetup); + const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(advisorBattleFlipPlayer(game, bg).forces['arrakeen:10'], 1); + assert.ok(advisorBattleFlipPlayer(game, bg).advisors?.arrakeen); + assert.equal(advisorBattleFlipPlayer(game, atreides).forces['arrakeen:10'], 10); + assert.equal(advisorBattleFlipPlayer(game, harkonnen).forces['carthag:11'], 10); + if (mutualTraitors) { + assert.ok(advisorBattleFlipPlayer(game, atreides).traitors.includes('harkonnen-3')); + assert.ok(advisorBattleFlipPlayer(game, harkonnen).traitors.includes('atreides-0')); + } + assert.deepEqual(advisorBattleFlipTraitors(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 && advisorBattleFlipClean(state)); + }); + act(bg, { type: 'endMovement' }); + reach(state => state.phase === 5 && state.active === atreides && advisorBattleFlipClean(state)); + const atreidesDeparture = transition(() => { + act(atreides, { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 9 }); + reach(advisorBattleFlipClean); + }); + act(atreides, { type: 'endMovement' }); + reach(state => state.phase === 5 && state.active === harkonnen && advisorBattleFlipClean(state)); + const harkonnenArrival = transition(() => { + act(harkonnen, { type: 'move', from: 'carthag:11', territory: 'arrakeen', sector: 10, amount: 1 }); + reach(advisorBattleFlipClean); + }); + reach(state => state.phase === 6 && advisorBattleFlipClean(state) && !!state.active); + const beforeBattle = structuredClone(game); + const choice = game.players.flatMap(player => viewGame(game, player.id).battleChoices) + .find(candidate => candidate.territory === 'arrakeen'); assert.ok(choice); + const battleStep: AdvisorBattleFlipStep = { actor: choice.chooser, action: { + type: 'chooseBattle', territory: 'arrakeen', + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker, + } }; + act(battleStep.actor, battleStep.action); + const beforePreparation = structuredClone(game); + assert.ok(game.battle && !game.battle.revealed && !Object.keys(game.battle.plans).length); + assert.equal(advisorBattleFlipPlayer(game, atreides).forces['arrakeen:10'], 1); + assert.equal(advisorBattleFlipPlayer(game, atreides).forces['imperial_basin:10'], 9); + assert.equal(advisorBattleFlipPlayer(game, harkonnen).forces['arrakeen:10'], 1); + assert.equal(advisorBattleFlipPlayer(game, harkonnen).forces['carthag:11'], 9); + assert.ok(advisorBattleFlipPlayer(game, bg).advisors?.arrakeen); + assert.ok(advisorBattleFlipPlayer(game, harkonnen).hand.some(card => card.id === karama.id)); + assert.deepEqual(advisorBattleFlipInventory(game), originalInventory); + assert.deepEqual(advisorBattleFlipTraitors(game), originalTraitors); + const atreidesPlan: Action = { type: 'battlePlan', leader: 'atreides-0', dial: 1, support: 1, weapon: null, defense: shield.id }; + const harkonnenPlan: Action = { type: 'battlePlan', leader: 'harkonnen-3', dial: 0, support: 0, weapon: explosion ? extra.id : null, defense: null }; + const plans: AdvisorBattleFlipStep[] = [ + { actor: atreides, action: atreidesPlan }, { actor: harkonnen, action: harkonnenPlan }, + ]; + return { game, source: structuredClone(game), initial, lobby, setup, beforeTraitorDeal, traitorDeal, + beforeStartingDeal, afterSetup, advisorSetup, openingOffer, declinedPreparation, + atreidesDeparture, holderDeparture: atreidesDeparture, harkonnenArrival, + beforeBattle, battleStep, beforePreparation, + bg, owner: bg, atreides, winner: atreides, harkonnen, holder: harkonnen, observer: harkonnen, + karama: karama.id, card: karama.id, shield: shield.id, ordinary: ordinary.id, extra: extra.id, + territory: 'arrakeen', sector: 10, location: 'arrakeen:10', event: game.battle.event, + mutualTraitors, explosion, atreidesPlan, harkonnenPlan, ownPlan: atreidesPlan, opponentPlan: harkonnenPlan, plans, planSteps: plans, + steps, actions: steps, sourceActionCount: steps.length, + originalInventory, originalTraitors, traitorOrder, traitorScalars, originalSpice, dealOrder, spiceOrder, + staging: [ + 'Actual public Advanced Bene Gesserit/Atreides/Harkonnen ready/startPreview with original base33 and no optional modules. Native prediction, private Traitor choices and advisorSetup place the original BG advisor beside Atreides’ ten Arrakeen10 counters.', + `Select only original scalar shuffle draws, four unopened Treachery positions and two unopened ordinary Spice positions. BG receives an ordinary weapon, Atreides Shield, and Harkonnen its actual two starting cards: Karama and ${explosion ? 'Lasgun' : 'an ordinary poison weapon'}. No hand is installed.`, + mutualTraitors + ? 'During the actual prediction action, before any original Traitor is dealt, select the original 15-card Fisher-Yates permutation so Atreides is offered and actually chooses Iakin, while Harkonnen naturally keeps Thufir in its original four-card packet.' + : 'The original Traitor shuffle preserves identity order; BG and Atreides make actual private choices and Harkonnen keeps its actual four-card packet. Battle continuation declines all original Traitor calls.', + 'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival. Decline the original preparation offer and end BG movement. Atreides actually moves nine Arrakeen counters to Basin10, leaving one with the peaceful advisor; Harkonnen uses actual city range to move one original Carthag11 counter to Arrakeen10, leaving nine at home.', + `Choose the actual one-versus-one Arrakeen battle, then stop before preparation and either sealed plan. Returned plans use original living Thufir5 plus dial1/support1 and the real Shield against original living Iakin2/dial0/support0 with ${explosion ? 'the original Lasgun' : 'no cards'}. The held original Harkonnen Karama remains unspent.`, + 'No played phase, army, wallet, Tanks, hand, leader status, stance, prevention, plan, casualty or receipt is installed. Battle resolution, mandatory advisor conversion and optional winner card cleanup remain actual caller-owned continuations.', + ], + }; +} +export type AdvisorBattleFlipFixture = ReturnType; + +/** Perform actual preparation, original plans and the original Traitor votes. + * Stop before the new conversion response, the real Shield winner choice, or + * the completed native no-winner boundary on a pre-feature runtime. */ +export function commitAdvisorBattleFlip(source: Game, fixture: AdvisorBattleFlipFixture, + steps: AdvisorBattleFlipStep[] = []) { + const before = structuredClone(source), start = steps.length; + let game = source; + for (let count = 0; count < 240; count++) { + if (game.response?.automaticAdvisorFlip || game.decision?.kind === 'battleCards' || !game.battle) { + assert.ok(advisorBattleFlipPlayer(game, fixture.holder).hand.some(card => card.id === fixture.karama)); + assert.deepEqual(advisorBattleFlipInventory(game), fixture.originalInventory); + return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)), + context: structuredClone(game.lastBattleContext), pendingDiscard: structuredClone(game.pendingTreacheryDiscard) }; + } + if (advisorBattleFlipClean(game) && !game.battle.revealed && !game.battle.preparation && + game.battle.preLeader?.closed !== false) { + const plan = fixture.plans.find(step => !game.battle!.plans[step.actor]); assert.ok(plan); + game = actAdvisorBattleFlip(game, plan.actor, plan.action, steps); continue; + } + if (advisorBattleFlipClean(game) && game.battle.revealed) { + const actor = viewGame(game, game.host).battle!.traitorVoters + .find(id => game.battle!.traitorCalls[id] === undefined); + if (actor) { + game = actAdvisorBattleFlip(game, actor, { type: 'traitorCall', call: fixture.mutualTraitors }, steps); + continue; + } + } + const next = nextAdvisorBattleFlip(game); assert.ok(next, 'Choose the original native battle control'); + game = actAdvisorBattleFlip(game, next.actor, next.action, steps); + } + throw Error('The actual advisor battle did not reach its committed loss boundary'); +} + +/** Call after explicitly resolving the conversion. Keep the real optional + * winner Shield, allow mandatory discard/aftermath, and reach Collection or + * Mentat through native controls. This never answers a conversion response. */ +export function finishAdvisorBattleFlip(source: Game, steps: AdvisorBattleFlipStep[] = []): Game { + let game = source; + for (let count = 0; count < 300; count++) { + if (!game.battle && game.phase >= 7 && advisorBattleFlipClean(game)) return game; + assert.ok(!game.response?.automaticAdvisorFlip, 'Resolve the original advisor-conversion choice before finishing'); + if (game.decision?.kind === 'battleCards' && !game.response && !game.phaseOpening) { + game = actAdvisorBattleFlip(game, game.decision.player, { type: 'decision', discard: [] }, steps); continue; + } + const next = nextAdvisorBattleFlip(game); assert.ok(next, 'Complete the original owned battle before Collection'); + game = actAdvisorBattleFlip(game, next.actor, next.action, steps); + } + throw Error('The original advisor battle continuation did not reach Collection'); +} diff --git a/tests/fixture-semuta-battle-karama.ts b/tests/fixture-semuta-battle-karama.ts index 4a63ba6c..fbd5c87f 100644 --- a/tests/fixture-semuta-battle-karama.ts +++ b/tests/fixture-semuta-battle-karama.ts @@ -3,6 +3,7 @@ import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPla import { botActions } from '../game/bots'; import { isAuditorLeader } from '../game/cards'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam'; export type SemutaBattleKaramaKind = 'capture' | 'choamAudit' | 'choamBattleIncome'; export const reloadBattleKarama = (game: Game): Game => JSON.parse(JSON.stringify(game)); @@ -114,6 +115,7 @@ export function semutaBattleKaramaFixture(kind: SemutaBattleKaramaKind) { game = applyAction(game, loser, { type: 'battlePlan', dial: 0, support: 0, leader: loserLeader.id, defense: defense.id }); for (const id of [winner, loser]) game = applyAction(game, id, { type: 'traitorCall', call: false }); + game = finishSemutaBattleOutcome(game); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); const earlierEvents = [game.pendingTreacheryDiscard!.batch.event]; game = passBattleKaramaOffer(game); diff --git a/tests/fixture-semuta-homeworld-battle-loser.ts b/tests/fixture-semuta-homeworld-battle-loser.ts index f21eef77..c693834c 100644 --- a/tests/fixture-semuta-homeworld-battle-loser.ts +++ b/tests/fixture-semuta-homeworld-battle-loser.ts @@ -5,7 +5,7 @@ import { splitLocation } from '../game/board'; import { nativeShipmentSources } from '../game/homeworld-options'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep, - createSemutaHomeworldChoamSetup } from './fixture-semuta-homeworld-choam'; + createSemutaHomeworldChoamSetup, finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam'; import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech'; @@ -127,7 +127,8 @@ export function createSemutaHomeworldLoserFixture(advanced = false, fullHolder = export function disposeSemutaHomeworldLoser(f: SemutaHomeworldLoserFixture, state = f.game, actions?: RicheseSkillsTechStep[]): Game { - return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions); + const game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions); + return finishSemutaBattleOutcome(game, actions); } /** Keep the winner's held played cards, then use original income/market controls diff --git a/tests/fixture-semuta-homeworld-battle-winner.ts b/tests/fixture-semuta-homeworld-battle-winner.ts index 1c91dfcc..c3d90827 100644 --- a/tests/fixture-semuta-homeworld-battle-winner.ts +++ b/tests/fixture-semuta-homeworld-battle-winner.ts @@ -4,7 +4,8 @@ import { nativeShipmentSources } from '../game/homeworld-options'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep, - createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; + createSemutaHomeworldChoamSetup, finishSemutaBattleOutcome, + holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; export type SemutaHomeworldWinnerKind = 'mandatory' | 'selected'; @@ -117,6 +118,7 @@ export function createSemutaHomeworldWinnerFixture( let action = finalResponse; if (kind === 'selected') { game = applySemutaHomeworldChoamStep(game, voters[1], finalResponse, actions); + game = finishSemutaBattleOutcome(game, actions); if (game.pendingTreacheryDiscard) { earlierEvents.push(game.pendingTreacheryDiscard.batch.event); game = passOffer(game, actions); @@ -134,6 +136,7 @@ export function createSemutaHomeworldWinnerFixture( export function disposeSemutaHomeworldWinner(f: SemutaHomeworldWinnerFixture, state = f.game, actions?: RicheseSkillsTechStep[]): Game { let game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions); + game = finishSemutaBattleOutcome(game, actions); if (f.kind === 'mandatory' && game.pendingTreacheryDiscard?.continuation.kind === 'battleResolved') game = passOffer(game, actions); return game; diff --git a/tests/fixture-semuta-homeworld-choam.ts b/tests/fixture-semuta-homeworld-choam.ts index 01321993..02738466 100644 --- a/tests/fixture-semuta-homeworld-choam.ts +++ b/tests/fixture-semuta-homeworld-choam.ts @@ -29,6 +29,20 @@ export function holdSemutaHomeworldChoamCard(game: Game, owner: string, name: st return moveSemutaEmperorCard(game, card.id, owner); } +/** Acknowledge only the actual final battle window; leave fresh discards and + * every subsequent card, casualty or aftermath choice to the caller. */ +export function finishSemutaBattleOutcome(state: Game, actions?: RicheseSkillsTechStep[]): Game { + let game = state; + while (game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) { + const window = game.battle.outcomeWindow; + const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); + assert.ok(actor); + game = applySemutaHomeworldChoamStep(game, actor, + { type: 'battleOutcomeReady', event: window.event }, actions); + } + return game; +} + /** Caller tests the requested boundary before each real native action. */ export function advanceSemutaHomeworldChoam( state: Game, until: (game: Game) => boolean, actions?: RicheseSkillsTechStep[], diff --git a/tests/semuta-homeworld-battle-winner.test.ts b/tests/semuta-homeworld-battle-winner.test.ts index c99e8504..066a4d5c 100644 --- a/tests/semuta-homeworld-battle-winner.test.ts +++ b/tests/semuta-homeworld-battle-winner.test.ts @@ -6,7 +6,7 @@ import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { quoteSpiceCollection } from '../game/board-resolution-quote'; import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; -import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { finishSemutaBattleOutcome, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; import { createSemutaHomeworldWinnerFixture, disposeSemutaHomeworldWinner, @@ -234,7 +234,7 @@ for (const advanced of [false, true]) for (const kind of ['mandatory', 'selected for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original losing, mandatory winning and selected cleanup are distinct fresh events`, () => { const f = createSemutaHomeworldWinnerFixture('mandatory', advanced); - let game = applyAction(f.game, f.actor, f.action); + let game = finishSemutaBattleOutcome(applyAction(f.game, f.actor, f.action)); const losingEvent = game.pendingTreacheryDiscard!.batch.event; assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.worthless]); assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), true,