From 152039be5b052d5ea20462c5313481ec595b6e5b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:43:11 +1100 Subject: [PATCH] Support cohort-eligible Ambassador leader and KH repeats Replace lifetime first-death caps with the selected provisional composition: first returns remain immediate, while later native leaders and KH must satisfy their existing ordinary death/cohort conditions. Extract one phase/slot/price-independent cohort quote for ordinary Revival and the independent Ambassador. Preserve ordinary timing, payment, prevention, native early service and one-leader controls. Snapshot an opened cohort before a free return makes a leader alive, preserving remaining ordinary returns. Keep Ecaz identity tracking and the shared Duke permission separate. KH retains activation/loss history and five native discs; update its marker above the actual opened owner cycle and old marker. Independent grants retain ordinary allowance and income policies. Explain eligibility in persistent controls and source-labelled reference. Verify related rules/controls, types/lint/build, controlled native/copied free-return-to-next-ordinary-Revival programmes layered on original public Basic paid-entry sources, controlled Advanced KH actual entry/return, income allow/cancel and twelve minimal legal policy continuations. Assigned repeat/cohort facts are explicitly controlled, not claimed original battle histories. Full modes, wider custody/module combinations, comprehensive assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment. --- components/ecaz-entry.tsx | 7 +- docs/CURRENT_STATUS.md | 24 +- docs/IMPLEMENTATION_STATUS.md | 25 ++ docs/RULE_DECISIONS.md | 2 +- docs/TLEILAXU_AMBASSADOR_RULES.md | 110 ++++++++- game/engine.ts | 11 +- game/reference.ts | 18 +- game/revival.ts | 64 +++-- game/tleilaxu-ambassador-leaders.ts | 64 +++-- tests/ambassador-repeat-leader-engine.test.ts | 183 +++++++++++++++ tests/fixture-ambassador-repeat-leaders.ts | 40 ++++ tests/tleilaxu-ambassador-leaders.test.ts | 222 +++++++++++++++++- 12 files changed, 719 insertions(+), 51 deletions(-) create mode 100644 tests/ambassador-repeat-leader-engine.test.ts create mode 100644 tests/fixture-ambassador-repeat-leaders.ts diff --git a/components/ecaz-entry.tsx b/components/ecaz-entry.tsx index daa07090..e8081f2f 100644 --- a/components/ecaz-entry.tsx +++ b/components/ecaz-entry.tsx @@ -288,8 +288,11 @@ export function EcazEntry({ allowances unchanged provisionally. When Tleilaxu is seated, a positive return can earn it one bank spice, sharing the beneficiary’s once-per-turn free-revival reward. Canceling income keeps the return. - This income interpretation is provisional. Ordinary leaders and - Atreides’ Kwisatz Haderach retain their first-death restriction. + This income interpretation is provisional. First-death leaders and + the first-killed Kwisatz Haderach need not wait for the ordinary + revival cohort. Further returns must satisfy their ordinary death + condition under the provisional interpretation. Kwisatz Haderach + keeps its separate next-cohort marker. Other revival interactions remain under development.

{entry.revival?.leaders.some(leader => leader.destination === 'setAside') &&

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e422d96b..0bce72da 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,27 @@ open mode gates. ## Current checkpoint and work +**Ambassador repeated leaders and KH — 24 October. Prototyped / Partial.** +A shared death-cohort quote removes the generic lifetime first-death cap while +retaining ordinary repeat conditions. First returns remain immediate; actual +openings persist before resurrection, including the all-first-dead seam. Ecaz +keeps native identity accounting and the independent shared Duke. KH retains +activation losses/five native discs and advances its marker beyond the actual +opened cycle. The composition and ordinary-slot independence remain provisional. + +A disjoint helper lane, bounded review and owner integration pass +**13 files114/114**, types/lint/build and three controlled gameplay/rendering +programmes. Two original Basic paid-entry sources receive explicitly assigned +repeat/cohort histories, then real free return, next-turn ordinary choice4 and +paid4 revival in27 continuations each. A separate controlled Advanced actual +entry returns KH with cohort3/old2 → marker4. Twelve existing legal policies +return a repeat and finish the original entrant. Tests also retain actual income +allow/cancel and first-death/ordinary timing boundaries. +[Source, interpretation, exact proof and limits](TLEILAXU_AMBASSADOR_RULES.md#ordinary-repeated-leaders--24-october-2026). +Full modes, wider custody/module combinations and final assurance/deployed +acceptance remain open. No strategy calibration or saved-room/reset/restart/ +deployment operation. + **Earlier Basic Truthtrance shipment promises — 24 October. Prototyped / Partial.** Bare classic Basic phases0–4 now bind the current-turn reserve event using current-board feasibility, remaining owned Charity/normal Revival and net current @@ -62,8 +83,7 @@ Tleilaxu now uses existing coalition/Face Dance mechanics. Six labelled controll Basic cases retain genuine setup, actual Nexus/alliance and funded shipments. Feedback corrected a Guild-ally tariff expectation and an idle-policy private driver; runtime alone reran on identical source. [Policy, exact proof and limits](TLEILAXU_AMBASSADOR_RULES.md#provisional-free-revival-income--24-october-2026). -Wider combinations, generic ordinary repeat eligibility, complete modes and final -assurance remain open. No optional-module opening, strategic calibration or +Wider combinations, complete modes and final assurance remain open. No optional-module opening, strategic calibration or saved-room/database/reset/restart/deployment operation in this batch. **Ecaz's Ambassador Duke revival — 24 October. Prototyped / Partial.** The diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d11e8512..865feab8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,30 @@ # Dune implementation status +## 24 October 2026 — Cohort-eligible Ambassador leader and KH repeats + +Use one phase/slot/price-independent death-cohort quote for normal Revival and +the independent Tleilaxu Ambassador. First returns stay immediate; repeats retain +their ordinary condition, including Ecaz identity bookkeeping and Advanced +Auditor permission. Preserve an opening before clearing death so the free return +does not close remaining ordinary choices. KH keeps its own token/loss history +and advances above both the actual owner cycle and its old marker. Ordinary +phase/payment/prevention/one-leader controls and native early service stay in +normal Revival. Persistent controls explain the provisional repeat composition. + +A disjoint helper lane, bounded review and owner integration pass13-file114/114, +types/lint/build, two controlled Basic return-to-next-Revival programmes and one +controlled Advanced KH actual-entry programme. Original public Basic sources +use72/73 actions before assigned native repeat/cohort histories; real free return, +entrant completion and paid4 ordinary revival use27 actions each. Advanced actual +entry3 followed by return/end2 yields cohort3/old2 → marker4. Twelve existing legal +profiles finish original entry; controlled income allow/cancel preserves deaths2. +Focused feedback exposed the lost all-first-dead opening and corrected a missing +Advanced transport-allowance fixture step. [Source, policy, exact proof and limits](TLEILAXU_AMBASSADOR_RULES.md#ordinary-repeated-leaders--24-october-2026). +This is provisional composition and controlled runtime evidence, not earned +repeat-battle histories or complete mode acceptance. Wider custody/modules, +comprehensive assurance and deployed acceptance remain open. No strategic tuning +or saved-room/reset/restart/deployment operation. + ## 24 October 2026 — Earlier Basic Truthtrance shipment commitments Extend bare classic Basic current-turn questions to phases0–4 with remaining diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index e405348c..a353b87b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -442,7 +442,7 @@ They supersede earlier software-absence claims only within their named scope. | --- | --- | --- | | Odd-force Ecaz Occupy | Main paragraph: ceiling contribution/loss, floor survivors; conflicting FAQ remains unresolved. | [Occupy](ECAZ_OCCUPY_RULES.md) | | New-ally Duke loan | Optional existing-disc loan after Ambassador alliance consent; one battle and unused turn-end set-aside. | [Duke acquisition](ECAZ_DUKE_ACQUISITION.md) | -| Tleilaxu Ambassador leader alternative | First-death own native leader, Atreides' first-killed KH, or Ecaz's independently revivable shared Duke returns free instead of forces. Duke reuses the canonical dead-disc permission and selected alive/set-aside result, preserves death history and any satisfied native cohort opening, and grants no automatic battle control. Only an Ecaz beneficiary may select Duke. Ordinary allowance independence remains provisional. A positive Ambassador return shares the actual beneficiary’s ordinary free-income entitlement once per turn; actual Ghola remains independently rewarded. Cancellation suppresses only the earned bank spice and retains the completed return and spent entitlement. This source composition is provisional; generic native repeat and wider custody envelopes stay separate. | [Ambassador income policy](TLEILAXU_AMBASSADOR_RULES.md#provisional-free-revival-income--24-october-2026), [shared Duke route](TLEILAXU_AMBASSADOR_RULES.md#ecaz-shared-duke-alternative--24-october-2026) | +| Tleilaxu Ambassador leader alternative | An immediate first-death own native leader or an ordinary-cohort-eligible repeat returns free instead of forces under the selected provisional composition; no Tleilaxu early-service waiver is imported. Snapshot/persist an actual opening before clearing death, including an all-first-dead opening. Ecaz retains native identity-cohort recording. Existing Advanced Auditor permission remains independent of the ordinary five-disc cycle. Atreides' KH keeps first-return permission; repeats require its saved marker in the actual ordinary cohort, which is preserved before advancing the marker above both cycles. Activation losses and five native discs remain. Ecaz's independently revivable shared Duke remains separate. Duke reuses the canonical dead-disc permission and selected alive/set-aside result, preserves death history and any satisfied native cohort opening, and grants no automatic battle control. Only an Ecaz beneficiary may select Duke. Ordinary allowance independence remains provisional. A positive Ambassador return shares the actual beneficiary’s ordinary free-income entitlement once per turn; actual Ghola remains independently rewarded. Cancellation suppresses only the earned bank spice and retains the completed return and spent entitlement. This source composition is provisional; wider custody/module envelopes stay separate. | [Native repeat policy](TLEILAXU_AMBASSADOR_RULES.md#ordinary-repeated-leaders--24-october-2026), [Ambassador income policy](TLEILAXU_AMBASSADOR_RULES.md#provisional-free-revival-income--24-october-2026), [shared Duke route](TLEILAXU_AMBASSADOR_RULES.md#ecaz-shared-duke-alternative--24-october-2026) | | Ordinary allied shipment transit | Depart during the shipper's turn; excess new physical visitors still present at ending go to Tanks, even when exit was prevented. Old co-occupation timing is separate. | [Allied transit](ALLIED_TRANSIT_RULES.md) | | Ordinary mixed No-Field battle | Private pool is physical forces plus reserve-limited token materialization; both plans seal before one reveal. Advanced now shares this provisional policy; combined Occupy remains separate. | [No-Field](RICHESE_NO_FIELD_RULES.md) | | Guild-recipient allied No-Field | Rounded one-force Guild fee: one spice to bank, even when Richese funds it; no Guild self-shipment income. | [Allied No-Field payment](RICHESE_ALLIED_SHIPMENT_AUDIT.md) | diff --git a/docs/TLEILAXU_AMBASSADOR_RULES.md b/docs/TLEILAXU_AMBASSADOR_RULES.md index 554d7c3a..36742af2 100644 --- a/docs/TLEILAXU_AMBASSADOR_RULES.md +++ b/docs/TLEILAXU_AMBASSADOR_RULES.md @@ -1,6 +1,114 @@ # Tleilaxu Ambassador revival: source contract -Primary-source review, 2026-09-07, with later runtime checkpoints below. This document supplements the [Ambassador effects audit](ECAZ_AMBASSADOR_EFFECTS_AUDIT.md) and [runtime readiness review](TLEILAXU_AMBASSADOR_RUNTIME_READINESS.md). Recorded source questions remain distinct from the Basic-first implementation choices. +Primary-source review, 2026-09-07, with later runtime checkpoints below. This document supplements the [Ambassador effects audit](ECAZ_AMBASSADOR_EFFECTS_AUDIT.md) and [runtime readiness review](TLEILAXU_AMBASSADOR_RUNTIME_READINESS.md). Recorded source questions remain distinct from the Basic-first implementation choices. The current repeated-leader/KH section supersedes first-death-only boundaries in the historical checkpoints below. + +## Ordinary repeated leaders — 24 October 2026 + +**Prototyped / Partial.** The free one-leader alternative no longer has a +lifetime first-death cap. Retain the immediate first-death own-leader permission; +a native leader killed again becomes eligible when its existing ordinary death +cohort permits return. Do not give it the Tleilaxu negotiated early-service +waiver. Atreides' KH keeps its activation and separate next-cohort marker; +its first return is immediate and repeats must meet that marker in the actual +ordinary cohort. Ecaz's shared Duke keeps its independent permission and +selected living/set-aside destination. +Captured or foreign-ghola discs do not become the beneficiary's own Tanks. + +The [E3 grant, printed8](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=8) +says one own leader without a lifetime death cap. The [base restriction, +printed9](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9) +controls repeat deaths. Under the standing choose-recommended-option instruction, +compose those sources by retaining their ordinary repeat condition while waiving +the initial opening for first-death leaders as before. This is an explicit +provisional interpretation, not a located Ambassador repeat FAQ. It supersedes +the generic first-death-only and first-killed-KH implementation boundaries +below, preserving the separate ordinary allowance interpretation. The base +[KH rule, printed17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17) +revives KH like a leader; this bounded repeat composition uses its existing +ordinary marker condition without adding it to the native roster. + +A shared phase/slot/price-independent cohort quote now supplies both ordinary +Revival and the Ambassador. Ordinary Revival keeps its actual phase, one-leader +allowance, payment, prevention and native early-service controls. The independent +Ambassador ignores those phase/slot/payment gates and leaves their usage counters +unchanged. Basic ordinary CHOAM cohorts retain their existing policy. The already +adopted Advanced Auditor permission (supplied PDF physical29/logical27, CHOAM E) +remains independent of the ordinary five-disc opening; composing it with this +free grant is provisional rather than a new publisher worked example. + +Snapshot an actually opened cohort **before** clearing death. Preserve its cycle +so the newly living leader cannot close the remaining ordinary returns. This +also fixes all-five-first-dead followed by an Ambassador first return, where no +repeat was selected but the ordinary opening was previously lost. Ecaz uses its +existing native identity/cohort record before mutation; Duke's death count does +not index that roster. The original physical identity, printed strength, deaths, +Traitor susceptibility and other native leaders remain. A closed first-death +special return does not manufacture an ordinary opening. Before returning KH, +preserve the actual opening and then set its next marker above both the owner +cycle and prior KH marker. Thus an actual cycle3 return with an older marker2 +advances to4, preventing another return in the same cohort. Loss activation +progress and the separate token's identity remain unchanged. + +Native and Bene Gesserit copied grants use the same private choices and actual +free return. Zero-price/ordinary-slot independence, exclusive leader-versus-force +selection, current event ownership, original entrant continuation and the existing +committed-return/income receipt remain. Persistent guidance explains repeat +eligibility before selection; no player acknowledgement or new phase is added. +Full modes, exceptional custody/module combinations and comprehensive assurance +remain unfinished. + +### Focused repeat proof and limits + +A disjoint shared-helper lane, bounded source/implementation review and owner +integration pass **13 files114/114**, `npm run check:quick`, production build and +three changed-path gameplay/rendering programmes. The initial focused case +reproduced a lost first ordinary opening (`0` instead of `1` after the free +return); preserving the quote repairs it and the next actual Revival offers +four remaining leaders. No ordinary phase/slot/price/prevention or native +Tleilaxu early-service gate was removed from normal Revival. + +The two Basic runtime programmes start from the original public +`tests/fixture-duke-ambassador.ts` sources (72/73 accepted actions through actual +paid entry), then explicitly assign **controlled five-disc repeated histories +and a prior completed cohort**. They are not earned repeat-death battle histories. +Each real native/BG-copy choice returns the same native disc free at deaths2, +retains cycle2 and the spent ordinary slot, explicitly finishes the original +entrant, then reaches next-turn ordinary Revival and pays4 to return another +leader. Each uses27 continuation actions and retains four ordinary choices after +the first free return. Existing physical source cards/forces remain unchanged. + +A separate controlled Advanced source in +`tests/fixture-ambassador-repeat-leaders.ts` uses the actual Guild shipment, +transport permission and Ambassador trigger (three source actions), followed by +KH return and original entrant completion. Actual cohort3 plus old marker2 yields +next marker4, with ten activation losses and five native discs retained. Its +histories are also assigned, not played Advanced setup/complete-game proof. +Twelve existing profiles (native/copy/KH × four levels) each make one legal free +return and finish the original entrant; no strategic tuning or calibration. + +Actual GameTable/EcazEntry rendering shows repeat policy, exact leader/KH +buttons, enabled/busy states, completed return and the later ordinary remainder. +Framework aliases/CSS stubs mean runtime rendering, not browser/visual/deployed +acceptance. Sixteen pure helper cases and ten engine cases cover open/closed +cohorts, first-death independence, identity bookkeeping, ordinary slot/zero price, +KH marker boundaries, event ownership/exclusivity, and native/copied receipt +income allowance/cancellation. Related ordinary, Auditor, Duke, revival-use and +income regressions remain in the focused union. The controlled Advanced KH +fixture initially omitted native transport permission; submitting its actual +allowance fixed the fixture before frozen proof. + +Frozen source: base `16f654071aa79763e325b7953ccfeb5d524e791c`, tree +`464b5a0363de992ddddd0c0d08dc3ec9a5bbe3e82a6e30cc4cb1ee26fcdd55f0`, +2,616 tracked/nonignored files, including only the supplied untracked PDF's hash. +The PDF is not committed or republished. Private logs, HTML, snapshots and runtime +results are in +`/home/quintine/.local/share/dune/checkpoints/20261024-ambassador-repeat-leaders/`; +`frozen-integrated/report.json` contains the complete passing batch. Final +checkpoint prose/reference changes are compared against all frozen file hashes. +Full modes, wider custody/module combinations, comprehensive assurance and +deployed acceptance remain open. No saved-room/database/reset/restart/deployment +operation or strategic calibration. + ## Provisional free revival income — 24 October 2026 diff --git a/game/engine.ts b/game/engine.ts index fb594685..289bc0c2 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -15,7 +15,7 @@ import { } from './leader-skill-combat'; import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate'; import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './tleilaxu-ambassador-forces'; -import { tleilaxuAmbassadorLeaderChoices, tleilaxuAmbassadorLeaderSelection } from './tleilaxu-ambassador-leaders'; +import { tleilaxuAmbassadorLeaderChoices, tleilaxuAmbassadorLeaderSelection, tleilaxuAmbassadorLeaderOpening } from './tleilaxu-ambassador-leaders'; import { createAmbassadorRevivalReceipt, validateAmbassadorRevivalReceipt, AmbassadorRevivalIncomeError, type AmbassadorRevivalReceipt, type AmbassadorRevivalReturn } from './tleilaxu-ambassador-income'; import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board'; @@ -18353,7 +18353,7 @@ function resolveAmbassadorEffect(g: Game) { g.decision = { kind: 'ecazAmbassador', player: p.id }; log( g, - `${p.name} may revive one first-death own leader or return up to four physical forces for free through the Tleilaxu Ambassador. Ordinary revival allowances and income are unchanged.`, + `${p.name} may revive one eligible own leader or return up to four physical forces for free through the Tleilaxu Ambassador. Repeated native leaders retain their ordinary death-cohort condition provisionally. Ordinary revival allowances remain independent; a positive return can earn the existing free-revival income.`, { faction: 'tleilaxu', name: 'Tleilaxu Ambassador' }, ); return; @@ -18610,6 +18610,8 @@ function decideAmbassador(g: Game, p: Player, action: Action) { let name: string, returned: AmbassadorRevivalReturn; if (selected === 'kwisatz') { const kwisatz = p.kwisatz!; + const opening = tleilaxuAmbassadorLeaderOpening(g, p); + if (opening !== null) p.revivalCycle = Math.max(p.revivalCycle, opening); kwisatz.dead = false; delete kwisatz.usedAt; kwisatz.revivalCycle = Math.max(p.revivalCycle, kwisatz.revivalCycle ?? 1) + 1; @@ -18625,7 +18627,12 @@ function decideAmbassador(g: Game, p: Player, action: Action) { returned = { kind: 'leader', leader: duke.id, deaths: duke.deaths }; g.dukeVidal = consumeDuke(g.dukeVidal!); } else { + // A living leader would hide a newly opened ordinary cohort: preserve + // its quote before resurrection. Ecaz records identity against that same + // original dead state before generic cycle persistence. + const opening = tleilaxuAmbassadorLeaderOpening(g, p); recordEcazLeaderReturn(g, selected); + if (opening !== null) p.revivalCycle = Math.max(p.revivalCycle, opening); selected.dead = false; delete selected.usedAt; name = selected.name; diff --git a/game/reference.ts b/game/reference.ts index de03fca7..9d9de4ae 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1500,8 +1500,9 @@ export const RULE_TOPICS: RuleTopic[] = [ title: 'Ecaz Ambassadors: placement and remaining effects', category: 'Advanced & expansions', coverage: 'Partial', + developmentStage: 'Prototyped', summary: - 'End-of-Revival placement, eight random entry effects, direct Ecaz Duke acquisition and consensual alliance formation are supported in development fixtures, with public token guides and private inspection history.', + 'End-of-Revival placement, entry effects, cohort-eligible free revival, Ecaz Duke acquisition and consensual alliances have connected preview controls and public token guides.', steps: [ 'Ecaz has its reusable Ambassador plus five randomly selected Ambassadors from the other ten identities. Inspect the public supply, placed tokens, triggered group and unused pool to read each effect.', 'Placed Ambassadors are face up by designer clarification. This implementation also shows the unplaced supply openly by default; that supply policy is an interpretation, not an explicit official FAQ ruling.', @@ -1522,7 +1523,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.', 'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.', 'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created child arrivals retain their own contracts. The native-module extension now composes ordinary paid arrival order with Atomics, Homeworlds and Nexus; other combinations and complete rules remain unfinished.', - 'The Tleilaxu Ambassador offers one eligible leader or up to four physical forces free. Ordinary native leaders retain their first-death limit; Atreides may choose its separate first-killed Kwisatz Haderach without adding a roster disc or resetting losses. An Ecaz beneficiary may instead return the original dead shared Duke through his independent permission, preserving death history and any native cohort opening. He becomes alive and set aside, unclaimed; a separate legal acquisition is needed before battle use. Ecaz’s ally cannot inherit that revival permission. Native and BG copied grants share private eligibility. Force/leader choices remain exclusive. Under the provisional income interpretation, a positive return earns seated Tleilaxu one bank spice per beneficiary per turn, sharing ordinary free revival; actual Ghola stays separate. Canceling income keeps the return and spent entitlement. Ordinary allowance independence and generic repeat/custody combinations remain provisional.', + 'The Tleilaxu Ambassador returns one eligible own leader or up to four physical forces free. First-death leaders and the first-killed Atreides KH need not wait for the ordinary cohort. Under the provisional repeat interpretation, later native leaders must satisfy their ordinary death condition, and later KH returns must meet their saved next-cohort marker. Preserve the actual opening before resurrection; KH advances its marker beyond both owner and old KH cycles without resetting activation losses or adding a native disc. Ecaz retains native identity bookkeeping. Ecaz may instead return the shared Duke alive and unclaimed under his independent permission; its ally cannot inherit that permission. Native and BG copied grants share eligibility and exclusive alternatives. A positive return provisionally earns seated Tleilaxu one bank spice per beneficiary/turn, sharing ordinary free revival; Ghola stays separate. Canceling income keeps the return and spent entitlement. Ordinary allowance independence and wider custody/module combinations remain provisional.', 'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Normal public Ecaz starts remain development previews; complete rules and unsupported combinations stay gated.', ], checklist: [ @@ -1530,21 +1531,21 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Original effects, copies, Duke acquisition, consent and token returns remain integrated. Ecaz’s Tleilaxu grant now returns the canonical shared dead Duke alive and set aside, preserving his history and satisfied cohort opening without a paid request, ordinary quota or extra disc. Positive Tleilaxu Ambassador returns now use the shared free-income ledger and a bound committed-return response; allow/cancel finishes the original arrival once. Bare Basic Ecaz+Ix with native Tleilaxu admits existing coalition/Face Dance mechanics; other combinations stay partial.', - evidence: ['game/engine.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/tleilaxu-ambassador-income.ts', 'game/karama-context.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'], + 'A shared ordinary death-cohort quote admits eligible native/KH repeats without importing normal phase/slot/price or native early-service gates into the independent grant. Preserve actual openings before native resurrection, retain Ecaz identity tracking and advance KH above its actual cycle while keeping five native discs and loss activation. Existing Duke, source-bound income and original entrant continuation remain; wider custody/modules stay partial.', + evidence: ['game/engine.ts', 'game/revival.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/tleilaxu-ambassador-income.ts', 'game/karama-context.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'], }, { area: 'Player controls', status: 'Partial', detail: - 'Existing placement, effect, consent and movement controls remain. An entitled Duke choice explains the alive/unclaimed destination; free-return and income controls explain the provisional shared reward and cancellation retaining the completed return. Earlier four actual390px native transport/income/BG journeys remain separate evidence; original entrant movement and once-only payment are retained.', + 'Persistent free-return guidance explains first-return and later-cohort eligibility, KH’s separate marker, ordinary-slot independence and the provisional shared income. Original exact native/KH/Duke buttons, busy controls, force alternatives and committed-return/income continuation remain. Earlier mobile arrival evidence stays separate.', evidence: ['components/ecaz-entry.tsx', 'components/game-table.tsx'], }, { area: 'AI', status: 'Partial', detail: - 'All four existing profiles legally select the original free Duke return and an eligible revival-income response, then finish the entrant turn: eight native/BG-copy policy continuations with Tleilaxu seated. Earlier placement, consent, income/acquisition and BG arrival evidence remains separate. No strategy refinement or calibration.', + 'Twelve existing legal profiles select repeated native/copy/KH choices and finish the original entrant, using server-private eligibility. Earlier original Duke/income and placement/consent evidence remains separate. No strategy refinement or strength calibration.', evidence: ['game/bots.ts', 'tests/ambassador-revival-income-engine.test.ts', 'tests/duke-ambassador-engine.test.ts', 'docs/TLEILAXU_AMBASSADOR_RULES.md'], }, { @@ -1558,8 +1559,11 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - 'Current income8-file81/81, types/lint/build, four original Basic native/copy allow/cancel gameplay/rendering programmes and eight legal policies pass. Original47/22 components, actual acquisition/death/paid Guild entry and dealt Karama precede the return and bound bank income. Six labelled Basic Tleilaxu coalition consumers retain actual setup/alliance/funded battles. Earlier Duke-only42 and arrival/browser evidence remain separate; full modes, wider combinations and deployed/final assurance stay open.', + 'Current repeat13-file114/114, types/lint/build and three controlled gameplay/rendering programmes pass: original Basic paid-entry sources plus assigned native histories reach the next actual ordinary Revival; separate Advanced actual entry returns KH with old2/cohort3 → marker4. Twelve legal policies finish original entry. First-opening, income allow/cancel and ordinary controls pass. Controlled histories are not earned repeat-battle/full-game proof. Earlier original Duke/income evidence stays separate; full modes, wider combinations, assurance and deployed acceptance remain open.', evidence: [ + 'tests/ambassador-repeat-leader-engine.test.ts', + 'tests/fixture-ambassador-repeat-leaders.ts', + 'tests/tleilaxu-ambassador-leaders.test.ts', 'tests/fixture-ambassador-revival-income.ts', 'tests/ambassador-revival-income-engine.test.ts', 'tests/tleilaxu-ambassador-income.test.ts', diff --git a/game/revival.ts b/game/revival.ts index 898ad7d9..f867fadd 100644 --- a/game/revival.ts +++ b/game/revival.ts @@ -5,7 +5,7 @@ import { isAuditorLeader } from './choam-auditor'; import { homeworldLowBonus } from './homeworld-benefits'; import { recruitsRevivalAllowance } from './recruits'; import { DUKE_VIDAL_ID } from './duke-vidal'; -import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival'; +import { ecazDukeRevivalBlock, quoteEcazDukeRevival, type EcazDukeRevivalContext } from './ecaz-duke-revival'; import { quoteEcazRevivalCohort, completeEcazRevivalCohortUse, recordEcazRevivalOpening, recordEcazFirstExecution, @@ -164,8 +164,45 @@ export function normalRevivalCycle( return cycle; } -/** Ordinary Ecaz cohorts keep native identity separate from independent Duke deaths. - * Only the absent-Tleilaxu card quote may waive the unopened first cohort. */ +export type LeaderRevivalCohortContext = EcazDukeRevivalContext; +export type LeaderRevivalCohortPlayer = Pick< + Player, 'id' | 'faction' | 'leaders' | 'revivalCycle' | 'ecazRevivalCohort' +>; +export type LeaderRevivalCohortQuote = { + cycle: number; + leaders: readonly string[]; + dukeAvailable: boolean; + dukeBlocked: string | null; +}; + +/** Ordinary death/cohort permission only, independent of phase, allowance, + * payment and prevention. The native Tleilaxu early service is separate. + * Ecaz keeps native identity separate from independent Duke deaths; only the + * absent-Tleilaxu card quote may waive the unopened first cohort. */ +export function quoteLeaderRevivalCohort( + g: LeaderRevivalCohortContext, + p: LeaderRevivalCohortPlayer, + waiveOpening = false, +): LeaderRevivalCohortQuote { + const shared = p.faction === 'ecaz' && g.dukeVidal; + const dukeContext = shared && g.status === 'playing' ? g : null; + const dukeBlocked = dukeContext ? ecazDukeRevivalBlock(dukeContext, p.id) : null; + const dukeAvailable = !!dukeContext && !dukeBlocked; + const cohort = shared ? quoteEcazRevivalCohort(p, dukeAvailable, waiveOpening) : null; + let cycle = cohort?.cycle ?? normalRevivalCycle(p); + if (waiveOpening && !cohort) cycle = Math.max(1, cycle); + const leaders = p.leaders.flatMap((leader) => { + if (!leader.dead || leader.capturedBy || leader.gholaBy) return []; + // Supplied Advanced p29 E permits Auditor revival each turn regardless + // of the ordinary five-disc cohort, including its repeated deaths. + const auditorReturn = g.advanced && p.faction === 'choam' && isAuditorLeader(leader); + return auditorReturn || (cycle > 0 && (cohort + ? !cohort.returned.includes(leader.id) : leader.deaths === cycle)) + ? [leader.id] : []; + }); + return { cycle, leaders, dukeAvailable, dukeBlocked }; +} + export function leaderRevivalOptions( g: Pick< Game, @@ -192,27 +229,14 @@ export function leaderRevivalOptions( waiveOpening = false, ) { const shared = p.faction === 'ecaz' && g.dukeVidal; - const dukeContext = shared && g.status === 'playing' ? g : null; - const dukeBlocked = dukeContext ? ecazDukeRevivalBlock(dukeContext, p.id) : null; - const dukeAvailable = !!dukeContext && !dukeBlocked; - const cohort = shared ? quoteEcazRevivalCohort(p, dukeAvailable, waiveOpening) : null; - let cycle = cohort?.cycle ?? normalRevivalCycle(p); - if (waiveOpening && !cohort) cycle = Math.max(1, cycle); + const { cycle, leaders: cohortLeaders, dukeAvailable, dukeBlocked } = + quoteLeaderRevivalCohort(g, p, waiveOpening); const enabled = g.phase === 4 && !revivalPrevented(g, p.id); const discount = revivalDiscount(g, p); const leaders = p.leaders.flatMap((leader) => { if (!enabled || !leader.dead || leader.capturedBy || leader.gholaBy) return []; - // Supplied Advanced p29 E permits Auditor revival each turn regardless - // of the other leaders in Tanks; retain the ordinary one-leader allowance. - const auditorReturn = - g.advanced && - p.faction === 'choam' && - isAuditorLeader(leader); - const normal = - !p.leaderRevived && - (auditorReturn || (cycle > 0 && (cohort - ? !cohort.returned.includes(leader.id) : leader.deaths === cycle))); + const normal = !p.leaderRevived && cohortLeaders.includes(leader.id); if ( !normal && (p.faction !== 'tleilaxu' || @@ -232,7 +256,7 @@ export function leaderRevivalOptions( ]; }); if (dukeAvailable && enabled && !p.leaderRevived) { - const quote = quoteEcazDukeRevival(dukeContext!, p.id, !!discount); + const quote = quoteEcazDukeRevival(g, p.id, !!discount); leaders.push({ id: DUKE_VIDAL_ID, name: quote.duke.leader.name, diff --git a/game/tleilaxu-ambassador-leaders.ts b/game/tleilaxu-ambassador-leaders.ts index b94d5ed5..dac934ee 100644 --- a/game/tleilaxu-ambassador-leaders.ts +++ b/game/tleilaxu-ambassador-leaders.ts @@ -2,17 +2,47 @@ import type { Game, Player } from './engine'; import type { Leader } from './cards'; import { createDukeVidal, DUKE_VIDAL_ID } from './duke-vidal'; import { resolveEcazDukeRevival, type EcazDukeRevivalContext } from './ecaz-duke-revival'; +import { + quoteLeaderRevivalCohort, type LeaderRevivalCohortPlayer, + type LeaderRevivalCohortQuote, +} from './revival'; type Context = Pick & Partial>; -type Beneficiary = Pick & Partial>; +type Beneficiary = Pick & + Partial>; export type TleilaxuAmbassadorLeaderChoice = { id: string; name: string; strength: number; destination?: 'setAside'; }; const dukeIdentity = createDukeVidal().leader; -const ownFirstDeath = (player: Beneficiary, leader: Leader) => - leader.id !== DUKE_VIDAL_ID && leader.faction === player.faction && leader.dead && leader.deaths === 1 && - !leader.capturedBy && !leader.gholaBy; +/** Legacy identity-only callers retain their first-death grant. Repeated + * returns need the actual seated owner's ordinary cohort, never invented facts + * or the native Tleilaxu negotiated early-revival service. */ +function ordinaryCohort(context: Context, player: Beneficiary): LeaderRevivalCohortQuote | null { + if (typeof player.id !== 'string' || !Number.isSafeInteger(player.revivalCycle) || + player.revivalCycle! < 0 || context.status !== 'playing' || + !Number.isSafeInteger(context.turn) || context.turn! <= 0 || + !Array.isArray(context.players) || + !context.players.some(seated => seated.id === player.id && seated.faction === player.faction)) return null; + try { + return quoteLeaderRevivalCohort(context as EcazDukeRevivalContext, player as LeaderRevivalCohortPlayer); + } catch { + return null; + } +} + +const ownLeader = (player: Beneficiary, leader: Leader, cohort: LeaderRevivalCohortQuote | null) => + leader.id !== DUKE_VIDAL_ID && leader.faction === player.faction && leader.dead && + !leader.capturedBy && !leader.gholaBy && + Number.isSafeInteger(leader.deaths) && + (leader.deaths === 1 || (leader.deaths > 1 && !!cohort?.leaders.includes(leader.id))); + +/** Snapshot an actually open ordinary cycle before an independent return + * clears death. This does not waive a closed first cohort or mutate its owner. */ +export function tleilaxuAmbassadorLeaderOpening(context: Context, player: Beneficiary): number | null { + const cycle = ordinaryCohort(context, player)?.cycle; + return cycle !== undefined && Number.isSafeInteger(cycle) && cycle > 0 ? cycle : null; +} /** Duke's independent Ecaz-only permission retains its original death history. * Reuse native identity/custody validation, projecting every unavailable source @@ -27,12 +57,16 @@ function sharedDuke(context: Context, player: Beneficiary): boolean { } } -/** Base p17 revives KH like a leader without adding it to the ordinary roster. - * Native KH death records start at cycle1; every existing return advances that - * marker. Repeat-return/ordinary-quota interpretation remains outside this grant. */ -function firstKwisatz(context: Context, player: Beneficiary): boolean { - return context.advanced && player.faction === 'atreides' && player.battleLosses >= 7 && - player.kwisatz?.dead === true && (player.kwisatz.revivalCycle ?? 1) === 1; +/** KH keeps its own next-cohort marker, never a sixth native disc. The first + * return remains immediate; later returns need the actual ordinary cohort. */ +function eligibleKwisatz(context: Context, player: Beneficiary): boolean { + if (!context.advanced || player.faction !== 'atreides' || !(player.battleLosses >= 7) || + player.kwisatz?.dead !== true) return false; + const marker = player.kwisatz.revivalCycle ?? 1; + if (!Number.isSafeInteger(marker) || marker < 1) return false; + if (marker === 1) return true; + const cycle = tleilaxuAmbassadorLeaderOpening(context, player); + return cycle !== null && cycle >= marker; } /** Return the actual own identity, never a foreign disc or a cloned physical leader. */ @@ -40,19 +74,21 @@ export function tleilaxuAmbassadorLeaderSelection( context: Context, player: Beneficiary, id: unknown, ): Leader | 'kwisatz' | typeof DUKE_VIDAL_ID | null { if (id === DUKE_VIDAL_ID) return sharedDuke(context, player) ? DUKE_VIDAL_ID : null; - if (id === 'kwisatz') return firstKwisatz(context, player) ? 'kwisatz' : null; - return player.leaders.find(leader => leader.id === id && ownFirstDeath(player, leader)) ?? null; + if (id === 'kwisatz') return eligibleKwisatz(context, player) ? 'kwisatz' : null; + const cohort = ordinaryCohort(context, player); + return player.leaders.find(leader => leader.id === id && ownLeader(player, leader, cohort)) ?? null; } export function tleilaxuAmbassadorLeaderChoices( context: Context, player: Beneficiary, ): TleilaxuAmbassadorLeaderChoice[] { const choices: TleilaxuAmbassadorLeaderChoice[] = []; + const cohort = ordinaryCohort(context, player); for (const leader of player.leaders) - if (ownFirstDeath(player, leader)) choices.push({ id: leader.id, name: leader.name, strength: leader.strength }); + if (ownLeader(player, leader, cohort)) choices.push({ id: leader.id, name: leader.name, strength: leader.strength }); if (sharedDuke(context, player)) choices.push({ id: DUKE_VIDAL_ID, name: dukeIdentity.name, strength: dukeIdentity.strength, destination: 'setAside', }); - if (firstKwisatz(context, player)) choices.push({ id: 'kwisatz', name: 'Kwisatz Haderach', strength: 2 }); + if (eligibleKwisatz(context, player)) choices.push({ id: 'kwisatz', name: 'Kwisatz Haderach', strength: 2 }); return choices; } diff --git a/tests/ambassador-repeat-leader-engine.test.ts b/tests/ambassador-repeat-leader-engine.test.ts new file mode 100644 index 00000000..504db9e0 --- /dev/null +++ b/tests/ambassador-repeat-leader-engine.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { createDukeAmbassadorFixture, actDukeAmbassador as act, + advanceDukeAmbassador as advance, dukeAmbassadorClean as clean } from './fixture-duke-ambassador'; +import { createAmbassadorRevivalIncomeFixture, actAmbassadorRevivalIncome as incomeAct } from './fixture-ambassador-revival-income'; +import { createControlledAmbassadorKhRepeatFixture } from './fixture-ambassador-repeat-leaders'; + +const player = (g: Game, id: string) => g.players.find(p => p.id === id)!; +/** Original public Basic source through the paid Guild arrival, then explicit + * controlled death/cohort/alliance inputs. These are not earned repeated deaths + * or a genuine original full-game history. All choices/continuations are real. */ +function scenario({ copied = false, owner = 'harkonnen', deaths = 2, open = true, + used = false, poor = false } = {}) { + const f = createDukeAmbassadorFixture({ copied }); + let game = structuredClone(f.offer); + const p = player(game, owner); + if (owner !== f.owner) { + player(game, f.owner).ally = owner; p.ally = f.owner; + } + p.revivalCycle = deaths - 1; + p.leaderRevived = used; + if (poor) p.spice = 0; + for (const [index, leader] of p.leaders.entries()) { + leader.dead = open || index === 0; + leader.deaths = open || index === 0 ? deaths : 0; + delete leader.usedAt; + } + if (owner === 'ecaz') p.ecazRevivalCohort = { + cycle: deaths - 1, returned: deaths > 1 ? p.leaders.map(l => l.id) : [], + }; + const event = game.pendingAmbassador!.event; + game = act(game, f.owner, { type: 'decision', event, trigger: true, beneficiary: owner, choice: 'effect' }); + if (copied) game = act(game, owner, { type: 'decision', event, effect: 'tleilaxu' }); + assert.equal(game.pendingAmbassador?.stage, 'revival'); + return { f, game, owner, event, leader: p.leaders[0].id }; +} +function reject(g: Game, actor: string, action: Action) { + const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before); +} +const counters = (g: Game, id: string) => { + const p = player(g, id); + return { spice: p.spice, tanks: p.tanks, reserves: p.reserves, hand: p.hand, + revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived }; +}; +const revive = (s: ReturnType, leader = s.leader) => + act(s.game, s.owner, { type: 'decision', event: s.event, leader }); + +void test('a controlled all-first-dead Ambassador return preserves the newly opened ordinary cohort', () => { + const s = scenario({ deaths: 1 }); + const done = revive(s); + assert.equal(player(done, s.owner).revivalCycle, 1); + assert.equal(player(done, s.owner).leaders.find(l => l.id === s.leader)!.dead, false); + const ordinary = advance(done, g => g.turn === 3 && g.phase === 4 && clean(g)); + assert.equal(viewGame(ordinary, s.owner).revival.leaders.length, 4); +}); + +for (const copied of [false, true]) void test(`controlled ${copied ? 'copied' : 'native'} Ambassador returns an eligible repeated leader and retains the next ordinary return`, () => { + const s = scenario({ copied, used: true }), before = structuredClone(s.game); + assert.ok(viewGame(s.game, s.owner).ambassadorEntry!.revival!.leaders.some(l => l.id === s.leader)); + const done = revive(s), returned = player(done, s.owner).leaders.find(l => l.id === s.leader)!; + assert.equal(returned.dead, false); assert.equal(returned.deaths, 2); assert.equal(returned.usedAt, undefined); + assert.equal(player(done, s.owner).revivalCycle, 2); + assert.deepEqual(counters(done, s.owner), counters(before, s.owner)); + assert.equal(done.pendingAmbassador, null); assert.equal(done.active, s.f.entrant); + reject(done, s.owner, { type: 'decision', event: s.event, leader: s.leader }); + let ordinary = advance(done, g => g.turn === 3 && g.phase === 4 && clean(g)); + const choices = viewGame(ordinary, s.owner).revival.leaders; + assert.equal(choices.length, 4); + const choice = choices.find(l => l.affordable); assert.ok(choice); + const balance = player(ordinary, s.owner).spice; + ordinary = act(ordinary, s.owner, { type: 'reviveLeader', leader: choice.id }); + assert.equal(player(ordinary, s.owner).leaders.find(l => l.id === choice.id)!.dead, false); + assert.equal(player(ordinary, s.owner).spice, balance - choice.cost); + assert.equal(player(ordinary, s.owner).leaderRevived, true); +}); + +void test('a controlled closed repeated cohort stays unavailable while a closed first-death leader retains the independent grant', () => { + const blocked = scenario({ open: false }); + assert.equal(viewGame(blocked.game, blocked.owner).ambassadorEntry!.revival!.leaders.some(l => l.id === blocked.leader), false); + reject(blocked.game, blocked.owner, { type: 'decision', event: blocked.event, leader: blocked.leader }); + const first = scenario({ open: false, deaths: 1, used: true, poor: true }); + const before = counters(first.game, first.owner), done = revive(first); + assert.equal(player(done, first.owner).revivalCycle, 0, 'An independent first death must not open a closed cohort'); + assert.deepEqual(counters(done, first.owner), before); +}); + +void test('a controlled Ecaz repeated Ambassador return records the native identity before clearing death', () => { + const s = scenario({ owner: 'ecaz' }), before = counters(s.game, s.owner); + assert.ok(viewGame(s.game, s.owner).ambassadorEntry!.revival!.leaders.some(l => l.id === s.leader)); + const done = revive(s), p = player(done, s.owner); + assert.equal(p.leaders.find(l => l.id === s.leader)!.deaths, 2); + assert.equal(p.revivalCycle, 2); + assert.deepEqual(p.ecazRevivalCohort, { cycle: 2, returned: [s.leader] }); + assert.deepEqual(counters(done, s.owner), before); + assert.equal(p.leaders.length, 5); + const ordinary = advance(done, g => g.turn === 3 && g.phase === 4 && clean(g)); + assert.equal(viewGame(ordinary, s.owner).revival.leaders.filter(l => l.id !== 'duke-vidal').length, 4); +}); + +void test('controlled repeated choices preserve event ownership, exclusive alternatives and zero price', () => { + const s = scenario({ used: true, poor: true }); + reject(s.game, s.owner, { type: 'decision', event: 'expired', leader: s.leader }); + reject(s.game, s.f.entrant, { type: 'decision', event: s.event, leader: s.leader }); + reject(s.game, s.owner, { type: 'decision', event: s.event, leader: s.leader, forces: 1 }); + const done = revive(s); + assert.equal(player(done, s.owner).leaders.find(l => l.id === s.leader)!.dead, false); + assert.equal(player(done, s.owner).spice, 0); + assert.equal(player(done, s.owner).leaderRevived, true); +}); + +void test('all four existing profiles select a legal repeated return when every beneficiary option is repeated', () => { + for (const copied of [false, true]) { + const s = scenario({ copied }); + // Controlled physical reallocation isolates the new leader alternative; + // ordinary policy prefers a force return whenever one remains available. + const p = player(s.game, s.owner); p.reserves += p.tanks; p.tanks = 0; + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(s.game, s.owner); view.players.find(p => p.id === s.owner)!.bot = difficulty; + const action = botActions(view)[0]; assert.equal(action.type, 'decision'); assert.ok(action.leader); + const done = act(s.game, s.owner, action); + assert.equal(player(done, s.owner).leaders.find(l => l.id === action.leader)!.dead, false); + assert.equal(player(done, s.owner).leaders.find(l => l.id === action.leader)!.deaths, 2); + assert.equal(done.pendingAmbassador, null); assert.equal(done.active, s.f.entrant); + } + } +}); + +void test('controlled repeated returns retain their original death receipt through native and copied income allowance or cancellation', () => { + for (const copied of [false, true]) { + const f = createAmbassadorRevivalIncomeFixture({ copied }), source = structuredClone(f.game); + const p = player(source, f.owner); + for (const leader of p.leaders) { leader.dead = true; leader.deaths = 2; } + p.revivalCycle = 1; p.ecazRevivalCohort = { cycle: 1, returned: p.leaders.map(l => l.id) }; + p.leaderRevived = true; + const leader = p.leaders[0].id, bankIncome = player(source, f.incomeOwner).spice; + const earned = incomeAct(source, f.owner, { type: 'decision', event: f.event, leader }); + assert.deepEqual(earned.pendingAmbassador!.revivalReceipt!.returned, { kind: 'leader', leader, deaths: 2 }); + assert.equal(earned.response?.source, 'ambassadorRevival'); + for (const canceled of [false, true]) { + let done = earned; + if (canceled) done = incomeAct(done, f.cancelActor, { type: 'card', card: f.cancelCard, mode: 'cancel' }); + while (done.response) done = incomeAct(done, + done.players.find(p => !done.response!.passed.includes(p.id))!.id, { type: 'passResponse' }); + assert.equal(done.pendingAmbassador, null); + assert.equal(player(done, f.owner).leaders.find(l => l.id === leader)!.dead, false); + assert.equal(player(done, f.owner).leaders.find(l => l.id === leader)!.deaths, 2); + assert.equal(player(done, f.owner).revivalCycle, 2); + assert.equal(player(done, f.owner).leaderRevived, true); + assert.equal(player(done, f.incomeOwner).spice, bankIncome + (canceled ? 0 : 1)); + done = incomeAct(done, f.entrantContinuation.actor, f.entrantContinuation.action); + assert.equal(done.movementRemaining?.includes(f.entrant) ?? false, false); + } + } +}); + +void test('a controlled repeated KH return advances past the actual newly opened cohort rather than its stale owner marker', () => { + const { game, event } = createControlledAmbassadorKhRepeatFixture(), before = counters(game, 'atreides'); + assert.ok(viewGame(game, 'atreides').ambassadorEntry!.revival!.leaders.some(l => l.id === 'kwisatz')); + const done = act(game, 'atreides', { type: 'decision', event, leader: 'kwisatz' }); + const p = player(done, 'atreides'); + assert.equal(p.revivalCycle, 3); assert.equal(p.kwisatz!.revivalCycle, 4); + assert.equal(p.kwisatz!.dead, false); assert.equal(p.kwisatz!.usedAt, undefined); + assert.equal(p.battleLosses, 10); assert.equal(p.leaders.length, 5); + assert.ok(p.leaders.every(l => l.dead && l.deaths === 3)); + assert.deepEqual(counters(done, 'atreides'), before); + assert.equal(done.pendingAmbassador, null); assert.equal(done.active, 'guild'); + const blocked = createControlledAmbassadorKhRepeatFixture(4); + assert.equal(viewGame(blocked.game, 'atreides').ambassadorEntry!.revival!.leaders.some(l => l.id === 'kwisatz'), false); + reject(blocked.game, 'atreides', { type: 'decision', event: blocked.event, leader: 'kwisatz' }); +}); + +void test('all existing profiles use the controlled repeated KH alternative without adding a native leader or ordinary slot', () => { + const { game } = createControlledAmbassadorKhRepeatFixture(2, true); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(game, 'atreides'); view.players.find(p => p.id === 'atreides')!.bot = difficulty; + const action = botActions(view)[0]; assert.equal(action.leader, 'kwisatz'); + const done = act(game, 'atreides', action), p = player(done, 'atreides'); + assert.equal(p.kwisatz!.revivalCycle, 4); assert.equal(p.leaders.length, 5); + assert.equal(p.leaderRevived, true); assert.equal(p.battleLosses, 10); + } +}); diff --git a/tests/fixture-ambassador-repeat-leaders.ts b/tests/fixture-ambassador-repeat-leaders.ts new file mode 100644 index 00000000..fc83e5c7 --- /dev/null +++ b/tests/fixture-ambassador-repeat-leaders.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { createGame, newPlayer, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors'; +import { createDukeVidal } from '../game/duke-vidal'; +import { actDukeAmbassador as act, type DukeAmbassadorStep } from './fixture-duke-ambassador'; +const player = (g: Game, id: string) => g.players.find(p => p.id === id)!; + +/** Controlled Advanced source, with the original actual shipment/trigger path. + * These assigned cohort/loss facts are not earned gameplay or start proof. */ +export function createControlledAmbassadorKhRepeatFixture(marker = 2, live = false) { + const actions: DukeAmbassadorStep[] = []; + const step = (game: Game, actor: string, action: Parameters[2]) => act(game, actor, action, actions); + let game = createGame('AMBASSADORKHREPEAT', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']); + game.players.push(newPlayer('atreides', 'Atreides', 'atreides'), newPlayer('guild', 'Guild', 'guild')); + Object.assign(game, { status: 'playing', turn: 2, phase: 5, storm: 18, active: 'guild', + order: ['guild', 'ecaz', 'atreides'], movementRemaining: ['guild', 'ecaz', 'atreides'], deck: baseDeck() }); + for (const p of game.players) { p.hand = []; p.forces = {}; p.reserves = 20; p.spice = 12; } + game.dukeVidal = createDukeVidal(); + const state = createAmbassadors(() => 0); + const cohort = ['tleilaxu', ...state.tokens.map(t => t.effect).filter(e => e !== 'tleilaxu' && e !== 'ecaz')].slice(0, 5); + state.cohort = state.tokens.filter(t => cohort.includes(t.effect)).map(t => t.id); + for (const token of state.tokens) { token.zone = token.effect === 'ecaz' || state.cohort.includes(token.id) ? 'supply' : 'pool'; token.location = null; } + const token = state.tokens.find(t => t.effect === 'tleilaxu')!; token.zone = 'placed'; token.location = 'arrakeen'; + validateAmbassadors(state); game.ecazAmbassadors = state; + const p = player(game, 'atreides'); p.ally = 'ecaz'; player(game, 'ecaz').ally = p.id; + p.revivalCycle = live ? 3 : 2; p.leaderRevived = true; p.battleLosses = 10; + p.kwisatz = { dead: true, revivalCycle: marker, usedAt: 'imperial_basin' }; + for (const leader of p.leaders) { leader.dead = !live; leader.deaths = 3; } + game = step(game, 'guild', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }); + while (game.response) game = step(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' }); + if (game.decision?.kind === 'guildShipment') { + game = step(game, game.decision.player, { type: 'decision', allow: true }); + while (game.response) game = step(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' }); + } + assert.equal(game.pendingAmbassador?.stage, 'offer'); + const event = game.pendingAmbassador!.event; + game = step(game, 'ecaz', { type: 'decision', event, trigger: true, beneficiary: 'atreides', choice: 'effect' }); + return { game, event, actions, sourceActionCount: actions.length }; +} diff --git a/tests/tleilaxu-ambassador-leaders.test.ts b/tests/tleilaxu-ambassador-leaders.test.ts index 703caaa2..4d65a234 100644 --- a/tests/tleilaxu-ambassador-leaders.test.ts +++ b/tests/tleilaxu-ambassador-leaders.test.ts @@ -1,9 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { leaders } from '../game/cards'; +import { createAuditorLeader, leaders } from '../game/cards'; +import type { Player } from '../game/engine'; +import type { FactionId } from '../game/catalog'; import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { quoteLeaderRevivalCohort } from '../game/revival'; import { tleilaxuAmbassadorLeaderChoices, + tleilaxuAmbassadorLeaderOpening, tleilaxuAmbassadorLeaderSelection, } from '../game/tleilaxu-ambassador-leaders'; @@ -19,6 +23,19 @@ function fixture(advanced = false, deaths = 1) { return { context, player }; } +function cohortFixture(faction: FactionId = 'emperor', advanced = false) { + const player: Pick = { + id: 'owner', faction, leaders: leaders(faction), battleLosses: 0, + revivalCycle: 0, leaderRevived: false, spice: 0, + }; + const context = { + advanced, status: 'playing' as const, turn: 3, players: [player], + dukeVidal: faction === 'ecaz' ? createDukeVidal() : undefined, + }; + return { context, player }; +} + // These are pure identity/custody inputs. Actual Ambassador acquisition, free // return, cohort recording and native arrival continuation belong to engine tests. void test('Basic and Advanced Ecaz receive the canonical shared Duke alternative with set-aside destination', () => { @@ -34,7 +51,7 @@ void test('Basic and Advanced Ecaz receive the canonical shared Duke alternative } }); -void test('independent Duke permission preserves repeated deaths without extending ordinary Ambassador repeat returns', () => { +void test('independent Duke permission preserves repeated deaths without inventing a sparse native cohort', () => { for (const deaths of [2, 4]) { const { context, player } = fixture(false, deaths); const first = player.leaders[0], repeated = player.leaders[1]; @@ -54,6 +71,207 @@ void test('independent Duke permission preserves repeated deaths without extendi } }); +void test('first-death grant remains immediate while only a real opened cohort is snapshotted', () => { + for (const faction of ['emperor', 'ecaz'] as const) { + const { context, player } = cohortFixture(faction); + const first = player.leaders[0]; + first.dead = true; first.deaths = 1; + const closed = structuredClone(context); + assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 0); + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, []); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, first.id), first); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), null); + assert.deepEqual(context, closed, 'The immediate grant never opens a closed ordinary cohort'); + + for (const leader of player.leaders) { leader.dead = true; leader.deaths = 1; } + const opened = structuredClone(context); + const opening = tleilaxuAmbassadorLeaderOpening(context, player); + assert.equal(opening, 1); + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.map(leader => leader.id)); + assert.deepEqual(context, opened, 'Quoting the all-five-first-dead opening does not record it'); + if (faction === 'emperor') { + first.dead = false; + assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 0, + 'Clearing death before recording the actual opening would lose it'); + player.revivalCycle = opening!; + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.slice(1).map(leader => leader.id)); + } + } +}); + +void test('repeated native leaders wait for their own ordinary death cohort and then become choices', () => { + const { context, player } = cohortFixture(); + player.revivalCycle = 1; + for (const leader of player.leaders) { leader.dead = true; leader.deaths = 1; } + const repeated = player.leaders[0]; + repeated.deaths = 2; + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.slice(1).map(leader => leader.id)); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), null); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 1); + + for (const leader of player.leaders) leader.deaths = 2; + const before = structuredClone(context); + assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 2); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player).map(choice => choice.id), player.leaders.map(leader => leader.id)); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), repeated); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 2); + assert.deepEqual(context, before); + + player.revivalCycle = 2; + for (const leader of player.leaders.slice(1)) { leader.dead = false; leader.deaths = 1; } + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), repeated, + 'An already opened second cycle survives the other leaders returning alive'); +}); + +void test('unavailable captured and foreign-ghola roster members retain the ordinary cohort exception', () => { + const { context, player } = cohortFixture('atreides', true); + const repeated = player.leaders[0]; + repeated.dead = true; repeated.deaths = 2; + player.revivalCycle = 1; + for (const leader of player.leaders.slice(1)) { + leader.deaths = 1; + leader.capturedBy = 'other'; + } + delete player.leaders[4].capturedBy; + player.leaders[4].gholaBy = 'other'; + assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 2); + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, [repeated.id]); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), repeated); + for (const leader of player.leaders.slice(1)) + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, leader.id), null); +}); + +void test('Ambassador repeats ignore ordinary phase slot price and prevention without importing early service', () => { + for (const faction of ['emperor', 'tleilaxu'] as const) { + const { context, player } = cohortFixture(faction); + player.revivalCycle = 2; + player.leaderRevived = true; + for (const leader of player.leaders) { leader.dead = true; leader.deaths = 2; } + const expected = quoteLeaderRevivalCohort(context, player); + for (const phase of [0, 2, 4, 5, 9]) { + const gated = { ...context, phase, revivalPrevention: { turn: 3, player: player.id } }; + const before = structuredClone(gated); + assert.deepEqual(quoteLeaderRevivalCohort(gated, player), expected); + assert.equal(tleilaxuAmbassadorLeaderSelection(gated, player, player.leaders[0].id), player.leaders[0]); + assert.equal(tleilaxuAmbassadorLeaderOpening(gated, player), 2); + assert.equal(player.spice, 0); + assert.equal(player.leaderRevived, true); + assert.deepEqual(gated, before); + } + player.revivalCycle = 0; + for (const leader of player.leaders.slice(1)) { leader.dead = false; leader.deaths = 0; } + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, []); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, player.leaders[0].id), null); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), null); + assert.equal(quoteLeaderRevivalCohort(context, player, true).cycle, 1, + 'The explicit absent-faction card waiver remains available to its original caller'); + assert.deepEqual(quoteLeaderRevivalCohort(context, player, true).leaders, [], + 'Even an explicit first-opening waiver does not waive a second death'); + } +}); + +void test('opened native cohorts still reject foreign captured and foreign-ghola dead identities', () => { + const { context, player } = cohortFixture(); + player.revivalCycle = 2; + for (const leader of player.leaders) { leader.dead = true; leader.deaths = 2; } + const captive = player.leaders[1], ghola = player.leaders[2]; + captive.capturedBy = 'other'; ghola.gholaBy = 'other'; + const foreign = leaders('harkonnen')[0]; + foreign.dead = true; foreign.deaths = 2; + player.leaders[3] = foreign; + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player).map(choice => choice.id), + [player.leaders[0].id, player.leaders[4].id]); + for (const leader of [captive, ghola, foreign]) + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, leader.id), null); +}); + +void test('Advanced Auditor repeated eligibility remains independent of the five ordinary CHOAM discs', () => { + const { context, player } = cohortFixture('choam', true); + const auditor = createAuditorLeader(); + auditor.dead = true; auditor.deaths = 4; + player.leaders.push(auditor); + const before = structuredClone(context); + assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 0); + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, [auditor.id]); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, auditor.id), auditor); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), null); + assert.equal(tleilaxuAmbassadorLeaderSelection({ ...context, advanced: false }, player, auditor.id), null); + assert.deepEqual(context, before); + + for (const leader of player.leaders.slice(0, 5)) { leader.dead = true; leader.deaths = 2; } + auditor.dead = false; + assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 2); + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.slice(0, 5).map(leader => leader.id)); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 2, + 'A living or more often killed Auditor neither opens nor delays the ordinary cycle'); +}); + +void test('Ecaz repeated permission follows identity cohorts rather than native or Duke death-number equality', () => { + const { context, player } = cohortFixture('ecaz'); + player.revivalCycle = 3; + player.ecazRevivalCohort = { cycle: 3, returned: player.leaders.slice(0, 4).map(leader => leader.id) }; + for (const leader of player.leaders) { leader.dead = true; leader.deaths = 8; } + const pending = player.leaders[4]; + pending.deaths = 2; + context.dukeVidal!.leader.dead = true; + context.dukeVidal!.leader.deaths = 7; + const before = structuredClone(context); + const quote = quoteLeaderRevivalCohort(context, player); + assert.equal(quote.cycle, 3); + assert.deepEqual(quote.leaders, [pending.id]); + assert.equal(quote.dukeAvailable, true); + assert.equal(quote.dukeBlocked, null); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, pending.id), pending); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, player.leaders[0].id), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player).map(choice => choice.id), [pending.id, DUKE_VIDAL_ID]); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 3); + assert.deepEqual(context, before); + + player.ecazRevivalCohort.returned.push(pending.id); + assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 4); + assert.deepEqual(quoteLeaderRevivalCohort(context, player).leaders, player.leaders.map(leader => leader.id)); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, pending.id), pending); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, player), 4); +}); + +void test('KH repeats require their own marker to be met by an actual ordinary cohort', () => { + const { context, player } = cohortFixture('atreides', true); + player.battleLosses = 7; + for (const leader of player.leaders) { leader.dead = true; leader.deaths = 2; } + player.kwisatz = { dead: true, revivalCycle: 2 }; + assert.equal(quoteLeaderRevivalCohort(context, player).cycle, 2); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, 'kwisatz'), 'kwisatz'); + player.kwisatz.revivalCycle = 3; + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, 'kwisatz'), null); + player.kwisatz.revivalCycle = 1; + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, 'kwisatz'), 'kwisatz'); +}); + +void test('missing ordinary cohort facts preserve first-death choices without inventing repeat permission', () => { + const { context, player } = cohortFixture('ecaz'); + player.revivalCycle = 2; + player.ecazRevivalCohort = { cycle: 2, returned: [] }; + for (const leader of player.leaders) { leader.dead = true; leader.deaths = 2; } + const first = player.leaders[0], repeated = player.leaders[1]; + first.deaths = 1; + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), repeated, + 'The complete source actually permits this repeated identity'); + const contexts = [ + { advanced: false }, + { ...context, turn: undefined }, + { ...context, players: [] }, + ]; + for (const sparse of contexts) { + assert.equal(tleilaxuAmbassadorLeaderSelection(sparse, player, first.id), first); + assert.equal(tleilaxuAmbassadorLeaderSelection(sparse, player, repeated.id), null); + assert.equal(tleilaxuAmbassadorLeaderOpening(sparse, player), null); + } + const legacy = { ...player, revivalCycle: undefined }; + assert.equal(tleilaxuAmbassadorLeaderSelection(context, legacy, first.id), first); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, legacy, repeated.id), null); + assert.equal(tleilaxuAmbassadorLeaderOpening(context, legacy), null); +}); + void test('a non-Ecaz beneficiary or unseated Ecaz identity cannot inherit shared Duke revival', () => { const { context, player } = fixture(); for (const faction of ['moritani', 'tleilaxu', 'emperor'] as const) {