diff --git a/components/game-table.tsx b/components/game-table.tsx index f6e78d87..2178c4a6 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2475,7 +2475,9 @@ export function GameTable({

{g.response.automaticAdvisorFlip.producer === 'guildTransport' ? 'The transport and payment are complete. These advisors are now alone and must become fighters.' - : 'The ground movement is 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.'} {' '}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 ae199088..55c44f41 100644 --- a/docs/ADVISOR_AUTOMATIC_FLIPS.md +++ b/docs/ADVISOR_AUTOMATIC_FLIPS.md @@ -1,5 +1,66 @@ # Mandatory advisor conversions +## Trip to Gamont before Mentat victory — 24 October 2026 + +**Prototyped / Partial.** Compose supplied physical29 B’s return of one other +player’s force during Mentat with physical17’s group-flip counter and physical28 E’s +alone conversion. The original Gamont declaration/counter and physical card +use/disposal finish first. The actual return, or completed empty No-Field reveal, +then creates a conversion occurrence before settlement or victory. Preventing +that conversion does not return another counter, restore the used card or undo +revelation. Earlier Gamont prevention still keeps the force/card unchanged. + +A phase8 public event binds actor, target, location, elite/returned metadata and +ordinary-versus-final Mentat scope to the held BG group. Card and optional Nexus +fuel provenance stay server-side in a separate parent; neither the public receipt +nor Karama source selection copies the hidden fuel identity. Post-commit +validation does not call pre-return availability again. Old ground/transport +source shapes stay phase5-only. The existing provisional continuously-alone hold +is unchanged; setup remains automatic. + +Both direct cost completion and the original Semuta disposal continuation defer +the final choamMentat chooser while the new parent owns the counter. The parent +is consumed before restoring that chooser once; ordinary Mentat receives none. +The callback does not check victory itself. Actual victory rejects unresolved +conversion parents, then evaluates only after the original final confirmation. + +[Native Gamont fixture](../tests/fixture-advisor-gamont-flip.ts) starts public +Advanced BG/Atreides/CHOAM with the original CHOAM35 deck. Unopened original +cards supply Gamont/Karama. Actual BG advisorSetup at Arrakeen, a paid two-force +shipment into Carthag and a one-force city-range move to Tabr earn two BG +strongholds. Atreides really moves nine counters out of Arrakeen, leaving its +last counter beside the advisor. Actual Collection and ordinary/final Mentat +controls retain that near-win. No played army, phase, wallet, hand, advisor hold, +response or winner is installed. + +[Native engine cases](../tests/advisor-gamont-flip-engine.test.ts) cover ordinary +and final timing, both conversion outcomes, earlier original-effect prevention, +parent mismatch, a fuel-free public response and existing profiles. Allowance +can earn BG’s real third stronghold and final win; prevention retains only two +and advances the turn. Pure cases include the empty-reveal metadata path; this +native fixture proves ordinary-counter return, not full No-Field/Nexus/Semuta +combinations. Frozen **18 files277/277**, types/lint/build, five native Gamont/response/ +GameTable SSR journeys and eight existing-profile paths pass. Original sources +take52 ordinary/56 final actions. Two earned BG strongholds plus the allowed +Arrakeen conversion produce an actual final BG win; preventing conversion leaves +two and advances the actual turn. Earlier original Gamont prevention retains +the outsider and card. The public response contains no fuel-card ID; its private +parent records provenance. An explicit malformed-input boundary annotation is +repaired without changing runtime validation; final frozen proof passes with +no driver repairs or moving source. + +Private reports, rendered HTML and hashes: +`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-gamont-flips/`. +`frozen-integrated-3/report.json` records fingerprint +`3833bcfc789c1a5765b71df68ad138d529429f5b64449159a08c8febf6cdc03a` +on base `d0f52c7bae4b2df9bf6bf087770850a4681f56de`. Prose/reference evidence is +reviewed and hashed separately afterward. Standard controls render; this is not +new component-callback/browser/mobile/deployed or complete-mode acceptance. + +Remaining withdrawals, casualties/aftermath, other special arrivals and matching +groups remain open. No complete mode, comprehensive assurance or release gate +opens from this prototype. + ## Guild transport and reserve returns — 24 October 2026 **Prototyped / Partial.** The same physical17 counter and physical28 E mandatory diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d6361a94..4616f629 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 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 +the effect and spent use. A phase8 public event contains no private card/fuel +identity; server-only provenance owns its parent. Both direct and Semuta cost +continuations defer the genuine final chooser. Consume the parent before restoring +it once; ordinary Mentat receives none. Victory waits for original confirmation, +with the same provisional continuously-alone hold. +[Source, earned victory and limits](ADVISOR_AUTOMATIC_FLIPS.md#trip-to-gamont-before-mentat-victory--24-october-2026). + +Disjoint helper/native-fixture lanes, bounded continuation review and root +integration pass **18 files277/277**, types/lint/build, five native Gamont/ +response/GameTable SSR journeys and eight legal existing-profile paths. Genuine +52/56-action public Advanced BG/Atreides/CHOAM sources earn two BG strongholds +through paid shipment and city-range movement, then leave Atreides’ actual last +counter with the original Arrakeen advisor. Allowed conversion earns a real +third stronghold/final win; prevention retains two and advances the turn. A +malformed-input test annotation is corrected without relaxing the strict source +shape. Empty reveal is pure-source evidence; full No-Field/Nexus/Semuta +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 @@ -39,11 +67,6 @@ is staged. Exact legacy ground receipts remain accepted. No AI refinement, full-mode/comprehensive/browser/deployed assurance or saved-room/D1/reset/restart/ deployment operation. -Next native conversion producers remain non-transport withdrawals (including -Trip to Gamont before Mentat victory), casualties/aftermath and other special -arrivals. Extend each original continuation before claiming complete mandatory -conversion cancellation; preserve setup’s automatic exception. - **Mandatory advisor conversion after ground movement — 24 October. Prototyped / Partial.** A native advisor group moving into an empty destination, or left alone by the last outsider’s ground departure, now opens Karama prevention after the physical diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4346e91b..fb06d7f1 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,53 @@ # Dune implementation status +## 24 October 2026 — Gamont conversion before final victory + +Compose the physical17/28 E advisor counter with physical29 B’s Mentat return. +Original Gamont cost/disposal and return/revelation commit once, then a distinct +phase8 source binds the held group. Earlier original-effect prevention still +keeps the last outsider/card. Conversion prevention preserves the completed effect +and spent use, retaining the already provisional continuously-alone hold. A +returned:false receipt supports completed empty No-Field revelation without +rechecking the now-consumed availability. + +Private card/optional Nexus provenance stays beside a seven-field public effect +receipt. No fuel identity enters the response or Karama source selection. The +source union retains exact phase5 ground/transport shapes and adds phase8 Gamont. +Both direct and Semuta continuations defer final choamMentat while the new parent +owns conversion. Consume that parent before restoring only the original final +chooser once; ordinary Mentat receives none. Callback completion does not declare +victory. Actual victory rejects unresolved conversion parents before evaluation. +Standard controls explain that the effect/card use is already complete. + +Frozen **18 files277/277**, types/lint/build, five native Gamont/response/GameTable +SSR journeys and eight existing-profile paths pass. Genuine52-action ordinary and +56-action final public Advanced BG/Atreides/CHOAM sources use CHOAM35, original +starting Gamont/Karama, actual advisor placement, paid two-force Carthag shipment, +one-force city-range Tabr movement and nine-force Atreides departure. Actual +Collection/Mentat earns BG two strongholds and an advisor beside the last outsider. +Allow conversion and final confirmation for a real three-stronghold BG victory; +prevent it and the genuine final check advances the turn with no winner. No +played army, phase, wallet, hand, hold, response or winner is installed. + +One narrow final-victory case and the first277-case union pass. Typecheck flags an +intentionally malformed Gamont object’s boundary cast. The initial replacement +hits an older matching test; restore that unrelated line and anchor the Gamont +case explicitly through unknown. Strict runtime validation is unchanged. Final +frozen277/277 and every scoped check pass without private smoke-driver repairs +or source changes during proof. Native evidence covers an ordinary counter +return; empty revelation is pure-source evidence, and full No-Field/Nexus/Semuta +and remaining-producer combinations remain open. + +[Contract and remaining scope](ADVISOR_AUTOMATIC_FLIPS.md#trip-to-gamont-before-mentat-victory--24-october-2026). +Private evidence: `/home/quintine/.local/share/dune/checkpoints/20261024-advisor-gamont-flips/`. +Frozen fingerprint: +`3833bcfc789c1a5765b71df68ad138d529429f5b64449159a08c8febf6cdc03a` +(2,737 nonignored files), on base `d0f52c7bae4b2df9bf6bf087770850a4681f56de`. +Final prose/reference overlay is separately reviewed and hashed. Standard rendered +controls do not certify a new component callback, browser/mobile/deployed, +complete mode or comprehensive assurance. No AI refinement, saved-room/D1/reset/ +restart/deployment operation. + ## 24 October 2026 — Advisor counter after committed Guild transport Extend the mandatory-conversion protocol to actual Guild cross-shipment and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index f370fa0a..b0ec286f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,18 @@ # Rule decision index +## Advisor conversion after Trip to Gamont — 24 October 2026 + +Compose supplied physical29 B’s Mentat return with physical17’s group counter and +physical28 E’s mandatory alone conversion. Complete original Gamont cost/disposal, +return/revelation first; prevent only the resulting conversion before victory. +Do not require an actual returned counter: a revealed empty No-Field can also +remove the last foreign presence. Preserve the same provisional continuously-alone +hold. Public phase8 evidence omits card/Nexus fuel identity; private provenance +stays in its parent. Both original direct and Semuta continuations defer the +final chooser until that parent is consumed. Ordinary Mentat gets no final +chooser, and the callback itself never declares victory. +[Source, earned victory and limits](ADVISOR_AUTOMATIC_FLIPS.md#trip-to-gamont-before-mentat-victory--24-october-2026). + ## Advisor conversion after committed Guild transport — 24 October 2026 Extend the existing physical17/28 E composition to Guild cross-shipment and diff --git a/game/advisor-automatic-flip.ts b/game/advisor-automatic-flip.ts index 41f513c6..5b95bec8 100644 --- a/game/advisor-automatic-flip.ts +++ b/game/advisor-automatic-flip.ts @@ -18,6 +18,16 @@ export type AutomaticAdvisorTransport = { group: Record; }; +export type AutomaticAdvisorGamont = { + event: string; + player: string; + target: string; + location: string; + elite: number; + returned: boolean; + mentat: boolean; +}; + export type AutomaticAdvisorFlipSource = { event: string; owner: string; @@ -28,6 +38,7 @@ export type AutomaticAdvisorFlipSource = { } & ( | { producer: 'groundMovement'; mover: string; move: number } | { producer: 'guildTransport'; transport: AutomaticAdvisorTransport } + | { producer: 'gamont'; gamont: AutomaticAdvisorGamont } ); export type AutomaticAdvisorFlipPlayer = ForcePresence & { @@ -48,6 +59,7 @@ export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard status: string; turn: number; phase: number; + choamMentatPending?: boolean; }; export class AutomaticAdvisorFlipError extends Error { @@ -80,7 +92,8 @@ function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFl } function currentWindow(ctx: AutomaticAdvisorFlipContext) { - return ctx.advanced === true && ctx.status === 'playing' && ctx.phase === 5 && positive(ctx.turn); + return ctx.advanced === true && ctx.status === 'playing' && + (ctx.phase === 5 || ctx.phase === 8) && positive(ctx.turn); } /** A supported committed producer opens a response for these current groups. @@ -158,6 +171,34 @@ function validateTransportSource( 'Automatic advisor conversion lost the original transported physical counters.'); } +function validateGamontSource( + ctx: AutomaticAdvisorFlipContext, + source: Extract, +) { + const receipt = source.gamont; + requireFlip(record(receipt) && + Object.keys(receipt).sort().join(',') === 'elite,event,location,mentat,player,returned,target' && + identifier(receipt.event) && identifier(receipt.player) && identifier(receipt.target) && + identifier(receipt.location) && (receipt.elite === 0 || receipt.elite === 1) && + typeof receipt.returned === 'boolean' && typeof receipt.mentat === 'boolean', + 'Automatic advisor conversion lost its exact public Trip to Gamont receipt.'); + const point = splitLocation(receipt.location); + requireFlip(receipt.location === `${point.territory}:${point.sector}` && + gameTerritories(ctx).some((place) => place.id === point.territory && place.sectors.includes(point.sector)) && + source.territory === point.territory, + 'Automatic advisor conversion must use the original current Gamont location.'); + const actors = ctx.players.filter((player) => player.id === receipt.player); + const targets = ctx.players.filter((player) => player.id === receipt.target); + requireFlip(actors.length === 1 && actors[0].faction === 'choam' && + targets.length === 1 && receipt.target !== receipt.player, + 'Automatic advisor conversion needs the original native CHOAM actor and distinct Gamont target.'); + if (receipt.returned) + requireFlip(nonnegative(targets[0].reserves) && targets[0].reserves >= 1, + 'Automatic advisor conversion lost the returned Gamont counter.'); + requireFlip(!receipt.mentat || ctx.choamMentatPending === true, + 'Automatic advisor conversion lost its final CHOAM Mentat opportunity.'); +} + /** Validate the same committed producer and exact held group before either response outcome. */ export function validateAutomaticAdvisorFlip( ctx: AutomaticAdvisorFlipContext, @@ -167,11 +208,14 @@ export function validateAutomaticAdvisorFlip( ((source.producer === 'groundMovement' && identifier(source.mover) && Object.keys(source).sort().join(',') === 'event,forces,move,mover,owner,phase,producer,territory,turn') || (source.producer === 'guildTransport' && - Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,territory,transport,turn')) && + 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')) && identifier(source.event) && identifier(source.owner) && identifier(source.territory) && - positive(source.turn) && source.turn === ctx.turn && source.phase === 5 && + positive(source.turn) && source.turn === ctx.turn && + source.phase === (source.producer === 'gamont' ? 8 : 5) && currentWindow(ctx) && source.phase === ctx.phase, - 'Automatic advisor conversion lost its original current-turn ground movement or Guild transport.'); + 'Automatic advisor conversion lost its original current-turn committed producer.'); const owners = ctx.players.filter((player) => player.id === source.owner); const owner = owners[0]; requireFlip(owners.length === 1 && owner?.faction === 'beneGesserit' && @@ -183,7 +227,8 @@ export function validateAutomaticAdvisorFlip( const movers = ctx.players.filter((player) => player.id === source.mover); requireFlip(movers.length === 1 && positive(source.move) && movers[0].moved === source.move, 'Automatic advisor conversion lost its original completed movement.'); - } else validateTransportSource(ctx, source); + } else if (source.producer === 'guildTransport') validateTransportSource(ctx, source); + else validateGamontSource(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 2df97e51..facfc57c 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 } from './advisor-automatic-flip'; +import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont } 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'; @@ -1264,6 +1264,7 @@ type RicheseAllyOffer = { export type Game = { pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[]; pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport; + pendingAutomaticAdvisorGamont?: { receipt: AutomaticAdvisorGamont; card: string; nexusEvent?: string }; advisorPreparation?: AdvisorPreparationWindow; ecazOccupyPreview?: boolean; /** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */ @@ -8026,8 +8027,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string log(g, 'The declared move was prevented without moving forces or spending a movement.'); } else if (next.kind === 'choamGamontDiscard') { completeChoamGamont(g, getPlayer(g, next.player), next.target, - next.location, next.elite ?? 0, next.noFieldEvent); - if (next.mentat) g.decision = { kind: 'choamMentat', player: next.player }; + next.location, next.elite ?? 0, next.noFieldEvent, + { card: next.card, mentat: next.mentat, nexusEvent: next.nexusEvent }); + if (next.mentat && !g.pendingAutomaticAdvisorGamont) + g.decision = { kind: 'choamMentat', player: next.player }; } else if (next.kind === 'choamJubbaDiscard') { completeChoamJubba(g, getPlayer(g, next.player), next.territory); offerChoamStorm(g); @@ -20293,6 +20296,9 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) { 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.'); automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source)); return source; } @@ -20341,6 +20347,40 @@ function finishAutomaticAdvisorTransport(g: Game) { openTerritoryEntry(g, p, transport.to, transport.sector, transport.amount, transport.elite, 'guildTransport'); } } +function prepareGamontAdvisorFlips(g: Game, owner: Player, target: Player, + from: string, elite: number, returned: boolean, + completed: { card: string; mentat?: boolean; nexusEvent?: string }) { + const site = splitLocation(from).territory; + const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g)) + .filter(group => group.territory === site); + if (!groups.length) return false; + requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorGamont && + !g.pendingAutomaticAdvisorTransport, 'Finish the original advisor conversion first.'); + // Public effect evidence never includes CHOAM Cunning's private fuel card. + const receipt: AutomaticAdvisorGamont = { event: crypto.randomUUID(), player: owner.id, + target: target.id, location: from, elite, returned, mentat: completed.mentat === true }; + g.pendingAutomaticAdvisorGamont = { receipt, card: completed.card, + ...(completed.nexusEvent ? { nexusEvent: completed.nexusEvent } : {}) }; + 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: 'gamont', gamont: receipt }; + getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event; + (g.pendingAutomaticAdvisorFlips ??= []).push(source); + } + openAutomaticAdvisorFlip(g); + return true; +} +function finishAutomaticAdvisorGamont(g: Game) { + const parent = g.pendingAutomaticAdvisorGamont; + if (!parent) return; + requireRule(g.status === 'playing' && g.phase === 8 && !g.pendingAutomaticAdvisorFlips?.length && + (!parent.receipt.mentat || g.choamMentatPending === true), + 'Finish the original advisor conversion before resuming Mentat.'); + delete g.pendingAutomaticAdvisorGamont; + if (parent.receipt.mentat) + g.decision = { kind: 'choamMentat', player: parent.receipt.player }; +} function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) { for (const entry of released) delete getPlayer(g, entry.player).advisors?.[entry.territory]; @@ -22358,7 +22398,10 @@ function decideSharedSpice(g: Game, id: string, action: Action) { } } -function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) { +function victory(g: Game, quote?: VictoryQuote) { + requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport && + !g.pendingAutomaticAdvisorGamont, 'Resolve mandatory advisor conversions before checking victory.'); + quote ??= currentVictoryQuote(g); applyAdvisorReleases(g, quote.released); g.winner = quote.winner; g.status = quote.status; @@ -27250,7 +27293,8 @@ function completeChoamJubba(g: Game, owner: Player, location: string) { } function completeChoamGamont( g: Game, owner: Player, targetId: string, from: string, - elite: number, noFieldEvent?: string, + elite: number, noFieldEvent: string | undefined, + completed: { card: string; mentat?: boolean; nexusEvent?: string }, ) { const target = getPlayer(g, targetId); if (noFieldEvent) revealPlayerNoField(g, target, 'gamont'); @@ -27260,6 +27304,7 @@ function completeChoamGamont( target.reserves++; if (target.elites) target.elites.reserves += elite; } + prepareGamontAdvisorFlips(g, owner, target, from, elite, returned, completed); observeOccupation(g); settleAdvisors(g); log(g, returned @@ -27789,7 +27834,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl } } completeChoamGamont(g, choam, pending.target!, pending.location!, - pending.elite!, pending.noFieldEvent); + pending.elite!, pending.noFieldEvent, + { card: used.id, mentat: pending.mentat, nexusEvent: pending.nexusEvent }); } else { if (g.semutaPreview && (used.name === 'La La La' || pending.nexusEvent) && !pending.storm && !pending.movement && !pending.mentat && @@ -27830,7 +27876,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl if (pending.storm && g.stormResolution) offerChoamStorm(g); if (pending.revival && g.pendingRevival) offerRevivalStop(g); if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g); - if (pending.mentat) g.decision = { kind: 'choamMentat', player: choam.id }; + if (pending.mentat && !g.pendingAutomaticAdvisorGamont) + g.decision = { kind: 'choamMentat', player: choam.id }; } else if (response.kind === 'richeseAuction') { const round = validateRicheseCacheCancellation(g, response); if (canceled) { @@ -28170,12 +28217,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' : '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' : '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); - else openAutomaticAdvisorFlip(g); + if (!g.pendingAutomaticAdvisorFlips?.length) { + finishAutomaticAdvisorTransport(g); + finishAutomaticAdvisorGamont(g); + } else openAutomaticAdvisorFlip(g); } else finishAdvisorReaction(g, response); } } else if (response.kind === 'capture') { diff --git a/game/karama-context.ts b/game/karama-context.ts index f9d08291..5cba6f56 100644 --- a/game/karama-context.ts +++ b/game/karama-context.ts @@ -182,6 +182,7 @@ const cancellationSources = { response.automaticAdvisorFlip ? { 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, 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 aed96239..8c648917 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3830,6 +3830,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Return one other player’s force to reserves before stronghold control is checked.', steps: [ + 'If the completed return or empty No-Field reveal leaves Bene Gesserit advisors alone, their mandatory conversion has a separate Karama response before victory. The return and card use stay committed. The provisional continuously-alone cancellation duration still applies; final confirmation resumes only after this counter.', 'In basic or advanced play, CHOAM may use Trip to Gamont during Mentat Pause. Select another player, an occupied board sector and an ordinary or elite force. One physical token returns to that player’s reserves; it is not killed, revived or shipped. If the selected Richese sector has a No-Field, it must reveal first. Return one force if any are present; the card is still used if there are none.', 'You may select forces in storm, peaceful Bene Gesserit advisors, or occupants inside the Hidden Mobile Stronghold. An elite force remains elite in reserves. No spice, revival allowance, battle-loss count or transport income changes. If removing the last opposing force leaves advisors alone, their stance settles before victory.', 'The declared card, recipient, sector and force type are public before a Karama response. Cancellation leaves both card and force in place and prevents retrying that physical card’s effect for the phase. If the card or selected force disappears during an interruption, no return occurs and the card is not discarded for the effect.', @@ -4312,6 +4313,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In Advanced, advisors may coexist with their ally without triggering the end-turn alliance constraint in either direction. Neither the advisors nor the allied fighters are lost for that coexistence. Bene Gesserit fighters remain subject to ordinary separation; Basic receives no new advisor exception.', 'After Fremen setup, choose the starting advisor’s territory and sector. Setup conversions remain automatic. During play, advisors alone must become fighters. A ground move into an empty territory or the last outsider’s ground departure now offers Karama prevention after the physical move, before fighter benefits. Allow to convert once; cancellation leaves the completed movement intact.', '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.', '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.', @@ -4324,7 +4326,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Implementation", "status": "Partial", - "detail": "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": "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.", "evidence": [ "game/advisor-automatic-flip.ts", "game/advisors.ts", @@ -4339,7 +4341,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Player controls", "status": "Partial", - "detail": "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 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.", "evidence": [ "docs/ADVISOR_AUTOMATIC_FLIPS.md", "components/advisor-preparation.tsx", @@ -4350,8 +4352,9 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "AI", "status": "Partial", - "detail": "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 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.", "evidence": [ + "tests/advisor-gamont-flip-engine.test.ts", "tests/advisor-transport-flip-engine.test.ts", "tests/advisor-automatic-flip-engine.test.ts", "game/bots.ts", @@ -4361,7 +4364,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Documentation", "status": "Partial", - "detail": "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": "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.", "evidence": [ "docs/ADVISOR_AUTOMATIC_FLIPS.md", "docs/ADVISOR_PREPARATION.md", @@ -4372,8 +4375,10 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Verification", "status": "Partial", - "detail": "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": "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.", "evidence": [ + "tests/advisor-gamont-flip-engine.test.ts", + "tests/fixture-advisor-gamont-flip.ts", "tests/advisor-transport-flip-engine.test.ts", "tests/fixture-advisor-transport-flip.ts", "tests/advisor-automatic-flip-engine.test.ts", diff --git a/tests/advisor-automatic-flip-quote.test.ts b/tests/advisor-automatic-flip-quote.test.ts index 11c28ce2..ad19eca2 100644 --- a/tests/advisor-automatic-flip-quote.test.ts +++ b/tests/advisor-automatic-flip-quote.test.ts @@ -9,11 +9,13 @@ import { type AutomaticAdvisorFlipContext, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, + type AutomaticAdvisorGamont, } from '../game/advisor-automatic-flip'; import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board'; type GroundSource = Extract; type TransportSource = Extract; +type GamontSource = Extract; function context(): AutomaticAdvisorFlipContext { return { @@ -333,3 +335,150 @@ void test('transport validation reads only public slot and physical deposit meta assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); } }); + +function gamont(returned = true, mentat = false) { + const { ctx, source: ground } = held(); + ctx.phase = 8; + ctx.players[1].faction = 'choam'; + ctx.players[2].reserves = returned ? 1 : 0; + ctx.players[2].noField = { deployed: null }; + const receipt: AutomaticAdvisorGamont = { + event: 'completed-trip-to-gamont', player: 'a', target: 'r', + location: 'imperial_basin:9', elite: 0, returned, mentat, + }; + const source: GamontSource = { + event: 'automatic-after-completed-gamont', owner: ground.owner, + territory: ground.territory, turn: ground.turn, phase: 8, + forces: ground.forces, producer: 'gamont', gamont: receipt, + }; + ctx.players[0].advisors = { + ...ctx.players[0].advisors, + imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event }, + }; + if (mentat) ctx.choamMentatPending = true; + return { ctx, source }; +} + +void test('phase-eight public groups can be quoted and Gamont validates with a real return or an empty No-Field reveal', () => { + const quiet = context(); + quiet.phase = 8; + const before = structuredClone(quiet); + assert.equal(quoteAutomaticAdvisorFlips(quiet).some((entry) => entry.territory === 'imperial_basin'), true); + assert.deepEqual(quiet, before); + for (const returned of [false, true]) { + const { ctx, source } = gamont(returned); + const original = structuredClone({ ctx, source }); + assert.deepEqual(ctx.players[2].forces, {}); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); + assert.deepEqual({ ctx, source }, original); + assert.deepEqual(Object.keys(source).sort(), ['event', 'forces', 'gamont', 'owner', 'phase', 'producer', 'territory', 'turn']); + assert.deepEqual(Object.keys(source.gamont).sort(), ['elite', 'event', 'location', 'mentat', 'player', 'returned', 'target']); + } +}); + +void test('only a final Gamont receipt requires the still-open native CHOAM Mentat flag', () => { + const { ctx, source } = gamont(true, true); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); + ctx.choamMentatPending = false; + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); + delete ctx.choamMentatPending; + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); + source.gamont.mentat = false; + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); +}); + +void test('Gamont metadata binds native CHOAM, the distinct target, current location, outcome and exact held group', () => { + const changes: ((ctx: AutomaticAdvisorFlipContext, source: GamontSource) => void)[] = [ + (ctx) => { ctx.advanced = false; }, + (ctx) => { ctx.status = 'lobby'; }, + (ctx) => { ctx.turn++; }, + (_ctx, source) => { source.gamont.event = ''; }, + (_ctx, source) => { source.gamont.player = 'missing'; }, + (ctx) => { ctx.players[1].faction = 'atreides'; }, + (ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; }, + (_ctx, source) => { source.gamont.target = source.gamont.player; }, + (_ctx, source) => { source.gamont.target = 'missing'; }, + (ctx) => { ctx.players = [...ctx.players, { ...ctx.players[2] }]; }, + (_ctx, source) => { source.gamont.location = 'missing:0'; }, + (_ctx, source) => { source.gamont.location = 'imperial_basin:8'; }, + (_ctx, source) => { source.gamont.location = 'imperial_basin:9.0'; }, + (_ctx, source) => { source.gamont.location = 'arrakeen:10'; }, + (_ctx, source) => { source.gamont.elite = -1; }, + (_ctx, source) => { source.gamont.elite = 2; }, + (_ctx, source) => { source.gamont.elite = 0.5; }, + (ctx) => { ctx.players[2].reserves = 0; }, + (ctx) => { delete ctx.players[2].reserves; }, + (_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; }, + (ctx) => { ctx.players[2].forces = { 'imperial_basin:10': 1 }; }, + (ctx) => { ctx.players[0].advisors = { imperial_basin: { aloneFlipHold: 'another-event' } }; }, + ]; + for (const change of changes) { + const { ctx, source } = gamont(); + change(ctx, source); + const before = structuredClone({ ctx, source }); + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); + assert.deepEqual({ ctx, source }, before); + } + const { ctx, source } = gamont(); + source.gamont.elite = 1; + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); + for (const malformed of [ + { ...source, gamont: null }, + { ...source, gamont: { ...source.gamont, returned: 'yes' } }, + { ...source, gamont: { ...source.gamont, mentat: 1 } }, + { ...source, gamont: { ...source.gamont, elite: '1' } }, + ]) { + assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError); + } +}); + +void test('private Gamont fuel and Nexus identifiers are rejected in either public source layer', () => { + const { ctx, source } = gamont(); + for (const malformed of [ + { ...source, card: 'private-fuel-card' }, + { ...source, fuel: 'private-fuel-card' }, + { ...source, nexusEvent: 'private-nexus-event' }, + { ...source, gamont: { ...source.gamont, card: 'private-fuel-card' } }, + { ...source, gamont: { ...source.gamont, fuel: 'private-fuel-card' } }, + { ...source, gamont: { ...source.gamont, nexusEvent: 'private-nexus-event' } }, + ]) { + assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError); + } +}); + +void test('producer phases stay distinct while the original ground and transport receipts remain accepted in phase five', () => { + for (const original of [held(), transported()]) { + const before = JSON.stringify(original.source); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source)); + assert.equal(JSON.stringify(original.source), before); + original.ctx.phase = 8; + assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError); + original.source.phase = 8; + assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError); + } + const { ctx, source } = gamont(); + ctx.phase = 5; + source.phase = 5; + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); +}); + +void test('post-Gamont validation does not ask for prior availability, a remaining target counter or private fuel', () => { + const { ctx, source } = gamont(false); + for (const player of ctx.players) { + for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'moved', 'shipped', 'gamontAvailable']) { + Object.defineProperty(player, field, { + get() { throw new Error(`Gamont advisor rule read ${player.id} ${field}`); }, + }); + } + } + for (const field of ['gamontAvailable', 'pendingAutomaticAdvisorGamont', 'choamMentatPending']) { + Object.defineProperty(ctx, field, { + get() { throw new Error(`Gamont advisor rule read private or prior ${field}`); }, + }); + } + Object.defineProperty(ctx.players[2], 'reserves', { + get() { throw new Error('An empty No-Field reveal has no returned counter to validate'); }, + }); + assert.deepEqual(ctx.players[2].forces, {}); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); +}); diff --git a/tests/advisor-gamont-flip-engine.test.ts b/tests/advisor-gamont-flip-engine.test.ts new file mode 100644 index 00000000..5f7c314d --- /dev/null +++ b/tests/advisor-gamont-flip-engine.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine'; +import { fighterCount, isAdvisor } from '../game/advisors'; +import { strongholdProgress } from '../game/victory-progress'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + createAdvisorGamontFlipFixture as fixture, + actAdvisorGamontFlip as act, + commitAdvisorGamontFlip as commit, + advisorGamontFlipPlayer as player, + advisorGamontFlipInventory as inventory, + passAdvisorGamontFlip as pass, + finishAdvisorGamontFlip as finish, +} from './fixture-advisor-gamont-flip'; + +function rejected(g: Game, actor: string, action: Action) { + const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); + assert.deepEqual(g, before); +} +const holds = (g: Game, id: string) => strongholdProgress(g).progress.find(p => p.player === id)!.strongholds.length; + +for (const final of [false, true]) for (const canceled of [false, true]) { + void test(`native ${final ? 'final' : 'ordinary'} Gamont ${canceled ? 'prevention' : 'allowance'} keeps the return and changes the genuine victory outcome`, () => { + const f = fixture({ final }), before = structuredClone(f.game); + assert.equal(holds(before, f.bg), 2); + const pending = commit(f.game, f).game; + assert.deepEqual(f.game, before); + assert.equal(pending.response?.kind, 'advisorFlip'); + const source = pending.response?.automaticAdvisorFlip; + assert.ok(source?.producer === 'gamont'); + assert.equal(source.gamont.mentat, final); + assert.equal(source.gamont.returned, true); + assert.equal(pending.decision, null); + assert.equal(pending.status, 'playing'); + assert.deepEqual(pending.winner, []); + assert.equal(player(pending, f.holder).forces[f.from], undefined); + assert.equal(player(pending, f.holder).reserves, player(before, f.holder).reserves + 1); + assert.equal(player(pending, f.holder).tanks, player(before, f.holder).tanks); + assert.equal(pending.discard.filter(c => c.id === f.card).length, 1); + assert.equal(fighterCount(player(pending, f.bg), f.territory), 0); + assert.equal(holds(pending, f.bg), 2); + rejected(pending, f.choam, { type: 'decision', done: true }); + assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama)); + const publicResponse = JSON.stringify(viewGame(pending, f.holder).response); + assert.ok(!publicResponse.includes(f.card), 'The public conversion never copies the spent fuel card'); + assert.equal(pending.pendingAutomaticAdvisorGamont?.card, f.card); + const returned = structuredClone(player(pending, f.holder)); + let done = canceled + ? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }) + : pass(pending); + assert.equal(player(done, f.holder).reserves, returned.reserves); + assert.equal(player(done, f.holder).tanks, returned.tanks); + assert.equal(player(done, f.holder).forces[f.from], undefined); + assert.equal(done.discard.filter(c => c.id === f.card).length, 1); + assert.equal(done.pendingAutomaticAdvisorGamont, undefined); + assert.equal(done.pendingAutomaticAdvisorFlips, undefined); + assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled); + assert.equal(holds(done, f.bg), canceled ? 2 : 3); + assert.equal(done.status, 'playing'); + assert.deepEqual(done.winner, []); + if (final) assert.deepEqual(done.decision, { kind: 'choamMentat', player: f.choam }); + else assert.equal(done.decision, null); + for (let read = 0; read < 3; read++) { + done = normalizeAutomaticGame(done); + assert.equal(done.status, 'playing'); + assert.equal(player(done, f.holder).reserves, returned.reserves); + } + assert.deepEqual(inventory(done), f.originalInventory); + const finished = finish(done); + if (canceled) { + assert.equal(finished.status, 'playing'); + assert.equal(finished.turn, before.turn + 1); + assert.deepEqual(finished.winner, []); + } else { + assert.equal(finished.status, 'finished'); + assert.deepEqual(finished.winner, [f.bg]); + assert.equal(finished.turn, before.turn); + } + assert.equal(finished.discard.filter(c => c.id === f.card).length, 1); + }); +} + +void test('preventing the original Gamont effect leaves the last outsider and final chooser intact without a conversion parent', () => { + const f = fixture(), declared = act(f.game, f.choam, f.action); + assert.equal(declared.response?.kind, 'choamWorthless'); + const done = act(declared, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }); + assert.equal(player(done, f.holder).forces[f.from], 1); + assert.equal(player(done, f.holder).reserves, player(f.game, f.holder).reserves); + assert.ok(player(done, f.choam).hand.some(c => c.id === f.card)); + assert.equal(done.pendingAutomaticAdvisorGamont, undefined); + assert.equal(done.pendingAutomaticAdvisorFlips, undefined); + assert.deepEqual(done.decision, { kind: 'choamMentat', player: f.choam }); + assert.equal(holds(done, f.bg), 2); + const next = finish(done); + assert.equal(next.status, 'playing'); assert.equal(next.turn, f.game.turn + 1); +}); + +void test('a changed Gamont parent rejects before spending a second card or returning a second force', () => { + const f = fixture(), pending = commit(f.game, f).game; + pending.pendingAutomaticAdvisorGamont = { ...pending.pendingAutomaticAdvisorGamont!, + receipt: { ...pending.pendingAutomaticAdvisorGamont!.receipt, target: f.bg } }; + rejected(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }); + assert.equal(pending.discard.filter(c => c.id === f.card).length, 1); + assert.ok(player(pending, f.holder).hand.some(c => c.id === f.karama)); +}); + +void test('the existing four profiles resolve ordinary and final conversion responses legally before victory', () => { + for (const final of [false, true]) for (const profile of DIFFICULTIES) { + const f = fixture({ final }), 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 = pass(act(pending, f.holder, actions[0])); + assert.equal(done.pendingAutomaticAdvisorGamont, undefined); + assert.equal(done.status, 'playing'); + assert.equal(player(done, f.holder).reserves, player(f.game, f.holder).reserves + 1); + const next = finish(done); + assert.ok(next.status === 'finished' || next.turn === f.game.turn + 1); + assert.equal(next.discard.filter(c => c.id === f.card).length, 1); + assert.deepEqual(inventory(next), f.originalInventory); + } +}); diff --git a/tests/fixture-advisor-gamont-flip.ts b/tests/fixture-advisor-gamont-flip.ts new file mode 100644 index 00000000..1ee393ce --- /dev/null +++ b/tests/fixture-advisor-gamont-flip.ts @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { + actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory, + advisorPreparationPlayer, nextAdvisorPreparationStep, +} from './fixture-advisor-preparation'; + +export type AdvisorGamontFlipStep = { actor: string; action: Action }; +export type AdvisorGamontFlipTransition = { before: Game; steps: AdvisorGamontFlipStep[]; after: Game }; +export const actAdvisorGamontFlip = actAdvisorPreparation; +export const advisorGamontFlipClean = advisorPreparationClean; +export const advisorGamontFlipInventory = advisorPreparationInventory; +export const advisorGamontFlipPlayer = advisorPreparationPlayer; + +/** Preserve native cards, decline preparation, and stop at the final original + * CHOAM chooser. Closing markets are finished without selling the held Gamont. */ +export function nextAdvisorGamontFlip(game: Game): AdvisorGamontFlipStep | null { + if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) { + if (game.decision?.kind === 'choamMentat') return null; + if (game.decision?.kind === 'advisorBattle') + return { actor: game.decision.player, action: { type: 'decision', accept: false } }; + } + return nextAdvisorPreparationStep(game); +} +export function advanceAdvisorGamontFlip(game: Game, until: (state: Game) => boolean, + steps: AdvisorGamontFlipStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The genuine Gamont chronology ended before its requested boundary'); + const next = nextAdvisorGamontFlip(game); + assert.ok(next, `Choose the original final CHOAM/private control at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actAdvisorGamontFlip(game, next.actor, next.action, steps); + } + throw Error('The native advisor/Gamont source did not reach its finite boundary'); +} +export function passAdvisorGamontFlip(game: Game, steps: AdvisorGamontFlipStep[] = []): Game { + for (let count = 0; game.response && count < 60; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actAdvisorGamontFlip(game, actor.id, { type: 'passResponse' }, steps); + } + assert.ok(!game.response); return game; +} + +/** Actual public Advanced E2 source. BG earns fighters in Carthag and Tabr, + * while its original advisor remains beside the last Atreides Arrakeen counter. + * Stop BEFORE the original Gamont declaration, in ordinary or final Mentat. + * No played force, phase, hand, wallet, stance, hold or receipt is assigned. */ +export function createAdvisorGamontFlipFixture(options: { final?: boolean } = {}) { + const final = options.final ?? true, bg = 'beneGesserit', holder = 'atreides', choam = 'choam'; + let game = createGame('ADVISORGAMONTFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true, ['choam']); + joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); + joinGame(game, newPlayer(choam, 'CHOAM', 'choam')); + const initial = structuredClone(game), steps: AdvisorGamontFlipStep[] = []; + const act = (actor: string, action: Action) => { game = actAdvisorGamontFlip(game, actor, action, steps); }; + const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorGamontFlip(game, until, steps); }; + const transition = (run: () => void): AdvisorGamontFlipTransition => { + 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.expansions, ['choam']); + 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 === 0)); + const originalInventory = advisorGamontFlipInventory(game); + assert.equal(originalInventory.length, 35); + const karama = game.deck.find(card => card.effect === 'karama'); + const gamont = game.deck.find(card => card.name === 'Trip to Gamont'); + const ordinary = game.deck.find(card => card.kind === 'projectile'); + assert.ok(karama && gamont && ordinary); + const selected = [ordinary, karama, gamont]; + 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: AdvisorGamontFlipTransition | null = null; + for (let count = 0; game.status === 'setup' && count < 100; count++) { + const next = nextAdvisorGamontFlip(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(advisorGamontFlipPlayer(game, bg).forces['arrakeen:10'], 1); + assert.ok(advisorGamontFlipPlayer(game, bg).advisors?.arrakeen); + assert.equal(advisorGamontFlipPlayer(game, holder).forces['arrakeen:10'], 10); + 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 && advisorGamontFlipClean(state)); + }); + const bgShipment = transition(() => { + act(bg, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0 }); + reach(advisorGamontFlipClean); + }); + assert.equal(advisorGamontFlipPlayer(bgShipment.before, bg).spice - advisorGamontFlipPlayer(game, bg).spice, 2); + assert.equal(advisorGamontFlipPlayer(game, bg).forces['carthag:11'], 2); + assert.ok(!advisorGamontFlipPlayer(game, bg).advisors?.carthag); + const bgMovement = transition(() => { + act(bg, { type: 'move', from: 'carthag:11', territory: 'sietch_tabr', sector: 14, amount: 1 }); + reach(advisorGamontFlipClean); + }); + assert.equal(advisorGamontFlipPlayer(game, bg).forces['carthag:11'], 1); + assert.equal(advisorGamontFlipPlayer(game, bg).forces['sietch_tabr:14'], 1); + assert.ok(!advisorGamontFlipPlayer(game, bg).advisors?.sietch_tabr); + act(bg, { type: 'endMovement' }); + reach(state => state.phase === 5 && state.active === holder && advisorGamontFlipClean(state)); + const holderDeparture = transition(() => { + act(holder, { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 9 }); + reach(advisorGamontFlipClean); + }); + assert.equal(advisorGamontFlipPlayer(game, holder).forces['arrakeen:10'], 1); + assert.equal(advisorGamontFlipPlayer(game, holder).forces['imperial_basin:10'], 9); + assert.ok(advisorGamontFlipPlayer(game, bg).advisors?.arrakeen); + reach(state => state.turn === 1 && state.phase === 8 && advisorGamontFlipClean(state)); + const ordinaryMentat = structuredClone(game), ordinaryMentatActionCount = steps.length; + if (final) reach(state => state.phase === 8 && !state.phaseOpening && !state.response && state.decision?.kind === 'choamMentat'); + assert.equal(game.status, 'playing'); assert.equal(game.turn, 1); assert.equal(game.phase, 8); + assert.equal(advisorGamontFlipPlayer(game, bg).forces['arrakeen:10'], 1); + assert.ok(advisorGamontFlipPlayer(game, bg).advisors?.arrakeen); + assert.equal(advisorGamontFlipPlayer(game, bg).forces['carthag:11'], 1); + assert.equal(advisorGamontFlipPlayer(game, bg).forces['sietch_tabr:14'], 1); + assert.equal(advisorGamontFlipPlayer(game, holder).forces['arrakeen:10'], 1); + assert.ok(advisorGamontFlipPlayer(game, holder).hand.some(card => card.id === karama.id)); + assert.ok(advisorGamontFlipPlayer(game, choam).hand.some(card => card.id === gamont.id)); + assert.deepEqual(advisorGamontFlipInventory(game), originalInventory); + const action: Action = { type: 'card', mode: 'choam', card: gamont.id, target: holder, from: 'arrakeen:10', elite: 0 }; + return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, lobby, setup, + beforeStartingDeal, afterSetup, advisorSetup, openingOffer, declinedPreparation, + bgShipment, bgMovement, holderDeparture, ordinaryMentat, ordinaryMentatActionCount, + final, bg, owner: bg, choam, actor: choam, holder, atreides: holder, + card: gamont.id, gamont: gamont.id, karama: karama.id, action, + declarationStep: { actor: choam, action } satisfies AdvisorGamontFlipStep, + territory: 'arrakeen', from: 'arrakeen:10', to: 'reserves', sector: 10, + nearWin: { fighters: ['carthag', 'sietch_tabr'], advisors: ['arrakeen'], lastOutsider: holder }, + steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder, + staging: [ + 'Actual public Advanced Bene Gesserit/Atreides/CHOAM ready/startPreview, prediction and private Traitor choices, with the original CHOAM35 deck and no optional module. Actual advisorSetup places BG’s starting advisor beside Atreides’ native ten Arrakeen counters.', + 'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. BG receives an ordinary weapon, Atreides its original Karama and CHOAM its original Trip to Gamont; no played hand or later card acquisition is staged.', + 'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival, and decline original advisor preparation. BG pays two to ship two reserves as fighters into empty Carthag11, then uses its real city movement range to move one original counter from Carthag11 to empty Sietch Tabr14.', + 'BG actually ends movement. Atreides actually moves nine original counters from Arrakeen10 to Imperial Basin10, leaving one beside the original advisor. Other native turns finish normally; there is no battle or assigned casualty. Actual Collection gives BG two fighter-held strongholds while the Arrakeen advisor remains peaceful.', + final + ? 'Actually complete ordinary Mentat readiness and its closing CHOAM market without selling Gamont. Stop at the original public final choamMentat decision before declaring the held card.' + : 'Stop at ordinary clean first Mentat before readiness and before declaring the held Gamont.', + 'No played phase, force, wallet, hand, stance, hold, prevention or receipt is installed. Gamont’s normal response, physical return, automatic advisor conversion and final victory continuation remain caller-owned outcomes.', + ], + }; +} +export type AdvisorGamontFlipFixture = ReturnType; + +/** Declare and allow only the original Worthless response, stopping before a + * new mandatory conversion response or at the completed native return boundary. + * The original final chooser is never answered by this helper. */ +export function commitAdvisorGamontFlip(source: Game, fixture: AdvisorGamontFlipFixture, + steps: AdvisorGamontFlipStep[] = []) { + const before = structuredClone(source), start = steps.length; + let game = actAdvisorGamontFlip(source, fixture.choam, fixture.action, steps); + for (let count = 0; count < 80; count++) { + const returned = (advisorGamontFlipPlayer(game, fixture.holder).forces[fixture.from] ?? 0) === 0 && + advisorGamontFlipPlayer(game, fixture.holder).reserves === advisorGamontFlipPlayer(before, fixture.holder).reserves + 1; + if (game.response?.automaticAdvisorFlip || (returned && !game.response && !game.pendingTreacheryDiscard)) { + assert.ok(returned, 'The original Gamont force returns before its advisor-conversion response'); + assert.ok(advisorGamontFlipPlayer(game, fixture.holder).hand.some(card => card.id === fixture.karama)); + return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) }; + } + assert.equal(game.response?.kind, 'choamWorthless', 'Allow only the original Gamont declaration before its committed return'); + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actAdvisorGamontFlip(game, actor.id, { type: 'passResponse' }, steps); + } + throw Error('The original Gamont did not reach its committed physical-return boundary'); +} + +/** Explicitly finish the original Mentat after resolving the caller's owned + * conversion choice. Native final confirmation may win or advance the turn. */ +export function finishAdvisorGamontFlip(source: Game, steps: AdvisorGamontFlipStep[] = []): Game { + const turn = source.turn; + let game = source; + for (let count = 0; count < 200; count++) { + if (game.status === 'finished' || game.turn > turn) return game; + const next = game.decision?.kind === 'choamMentat' && !game.response && !game.phaseOpening + ? { actor: game.decision.player, action: { type: 'decision', done: true } satisfies Action } + : nextAdvisorGamontFlip(game); + assert.ok(next, 'Complete the original owned Gamont continuation before final Mentat'); + game = actAdvisorGamontFlip(game, next.actor, next.action, steps); + } + throw Error('The original Gamont Mentat continuation did not finish'); +}