diff --git a/components/ecaz-entry.tsx b/components/ecaz-entry.tsx index 020b29dd..619f44e7 100644 --- a/components/ecaz-entry.tsx +++ b/components/ecaz-entry.tsx @@ -283,12 +283,16 @@ export function EcazEntry({ ) : entry.stage === 'revival' ? ( <>

- Revive one eligible first-death own leader, including Atreides’ first - killed Kwisatz Haderach, or return up to {entry.revival?.maximum ?? 0} + Revive one eligible leader or return up to {entry.revival?.maximum ?? 0} {' '}physical forces for free. This independent grant leaves ordinary - allowances and income unchanged provisionally. Repeat-return and - shared-leader interactions remain unfinished. + allowances and income unchanged provisionally. Ordinary leaders and + Atreides’ Kwisatz Haderach retain their first-death restriction. + Other revival interactions remain under development.

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

+ Duke returns alive to the shared area, unclaimed. A separate legal + acquisition is needed before he can fight. +

} {entry.revival?.blocked && (

{entry.revival.blocked}

)} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 77fdcb50..d2088aa1 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,26 @@ open mode gates. ## Current checkpoint and work +**Ecaz's Ambassador Duke revival — 24 October. Prototyped / Partial.** The +original Tleilaxu grant now offers Ecaz the real shared dead Duke, including his +existing independent death histories. Native and BG-copy effects use canonical +identity/custody validation and the selected alive/set-aside return. Ordinary +first-death/KH limits and provisional independent allowance/income remain. +Persistent controls explain that revival grants no battle control. Record any +satisfied native cohort opening before clearing death; add no roster disc/skill. + +A disjoint helper lane, original-source fixture lane, bounded source review and +owner integration pass **5 files42/42**, types/lint/build, two original Basic +gameplay/rendering programmes and eight legal policy continuations. Real paid +placements1+2, acquisition, two poison plans and Duke death precede the next-turn +Guild entry/cost1. Sources72/73 then use two actions for free return and original +entrant completion, preserving strength6/deaths1 and resources. Controlled +Advanced/repeat/cohort/ownership/event/reacquisition probes are separate. Fixture +expectations and a private disabled-attribute assertion needed feedback repair; +runtime alone reran with source unchanged. [Sources, exact proof and limits](TLEILAXU_AMBASSADOR_RULES.md#ecaz-shared-duke-alternative--24-october-2026). +No module opening, broad assurance, complete-mode or browser/deployed claim. +No saved-room/database/reset/restart/deployment operation in this rule batch. + **Original Ix Spice cards — 24 October. Prototyped / Partial.** The historical Thumper/Sandtrout/Harvester audit now records explicit provisional first-turn precedence, later generated-worm suppression and sequential physical doubling. diff --git a/docs/ECAZ_REVIVAL_RULES.md b/docs/ECAZ_REVIVAL_RULES.md index 81ec2dac..30efb847 100644 --- a/docs/ECAZ_REVIVAL_RULES.md +++ b/docs/ECAZ_REVIVAL_RULES.md @@ -15,6 +15,10 @@ and follows the normal stop, discount-cancellation and payment/income windows. Ghola spends the actual card instead of spice or that slot. Strength remains six; both paths preserve the physical death history. +The same selected destination also applies to Ecaz's free Tleilaxu Ambassador +return; see its [shared Duke route](TLEILAXU_AMBASSADOR_RULES.md#ecaz-shared-duke-alternative--24-october-2026). +That independent grant retains its own provisional allowance/income policy. + Ecaz's first ordinary cohort opens with five dead discs counting Duke, or when its entire native five-disc roster is unavailable under the existing captured-leader exception. The opened first cohort persists across returns. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3abcbc3c..6c6fff60 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,27 @@ # Dune implementation status +## 24 October 2026 — Ecaz's free Ambassador Duke return + +Connect the missing shared-disc alternative to the original Tleilaxu grant for +Ecaz only. Native and BG-copy effects validate canonical dead Duke custody and +return the same disc alive/set-aside under the selected policy, preserving death +history and any satisfied native cohort opening. Add no native disc, control, +skill, paid request or ordinary allowance debit. Persistent controls explain the +required separate acquisition; original entrant movement remains available. + +A disjoint helper lane and genuine-fixture lane, bounded source review and owner +integration pass5-file42/42, types/lint/build, two original Basic gameplay/rendering +programmes and eight legal policies. Sources72/73 use real paid placements1+2, +acquisition and a two-poison Duke death, then turn2 Guild entry/cost1. Free return +and original entrant completion use two more actions with unchanged resources, +strength6, deaths1 and five native discs. Controlled Advanced/repeat/cohort/ +ownership/event/decline/reacquisition evidence remains separate. Fixture control +expectations and a private disabled-attribute assertion were repaired; runtime +alone reran on identical source. [Canonical source, policy, scope and frozen proof](TLEILAXU_AMBASSADOR_RULES.md#ecaz-shared-duke-alternative--24-october-2026). +Generic ordinary repeat/income policy, wider combinations, complete modes and +browser/deployed/final assurance remain open. No strategy refinement or saved- +room/database/reset/restart/deployment operation in this rule batch. + ## 24 October 2026 — Original Ix Spice card interpretations Retain existing Thumper/Sandtrout/Harvester mechanics after bounded printed-source diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index a4637753..254db65e 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, or Atreides' source-supported separate first-killed KH, revives free instead of forces. Ordinary allowance independence remains provisional; repeat returns/shared custody stay separate. | [Ambassador](TLEILAXU_AMBASSADOR_RULES.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/income independence remains provisional; generic native repeat and wider custody envelopes stay separate. | [Ambassador source and 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 9103eef7..1db3581a 100644 --- a/docs/TLEILAXU_AMBASSADOR_RULES.md +++ b/docs/TLEILAXU_AMBASSADOR_RULES.md @@ -2,6 +2,93 @@ 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. +## Ecaz shared Duke alternative — 24 October 2026 + +The original Tleilaxu Ambassador now offers Ecaz its dead shared Duke as the +one-leader free alternative. The previous selector searched only the ordinary +native roster and separate KH, so it omitted the real disc in `Game.dukeVidal`. +Both a native Tleilaxu token and the original Bene Gesserit copy use the same +beneficiary-specific selection. Ecaz's ally cannot inherit this permission. + +The [publisher's E3 rules, printed pages 8–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=8) +provide the free own-leader grant and Ecaz's independent Duke revival permission, +with Duke revival restricted to Ecaz. A bounded official-domain indexed check +corroborated the grant and independent revival paragraph this checkpoint. This +is their composition with the existing [selected living/set-aside custody](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026), +not a new publisher worked example or numerical ruling. Reuse the canonical +dead-Duke resolver, including its preserved repeated-death histories and existing +custody/profile limits. The ordinary Ambassador first-death and KH limits remain; +they do not add a new death-count cap to Duke's independent permission. + +Commit against the original shared disc: record any satisfied native Ecaz cohort +opening **before** clearing Duke's death, preserve his ID, strength6 and death +count, clear obsolete use/concealment, and release control/source/acquisition +through the existing shared-disc return. The result is alive and unclaimed in +the shared area, never appended to Ecaz's five native leaders. A separate legal +acquisition is required before battle use. Persistent owner-only guidance states +that result before the free-return button is pressed. + +The return costs0, creates no paid Revival request, spends no Ghola and grants no +skill. The Ambassador's existing provisional independence from ordinary leader +allowance and income is preserved. Force and leader alternatives remain +exclusive. The original Ambassador finishes once and the committed entrant +retains its remaining movement. Generic ordinary repeated-leader eligibility, +Tleilaxu income policy, wider captured/module envelopes and full acceptance stay +separate. Earlier shared-Duke absence/destination notes below are superseded only +for this route; they remain historical source analysis. + +### Focused proof and remaining scope + +`game/tleilaxu-ambassador-leaders.ts` delegates shared identity/custody validation +to `resolveEcazDukeRevival` and projects a canonical set-aside choice. Pure +`tests/tleilaxu-ambassador-leaders.test.ts` cases keep old sparse ordinary/KH +callers working and distinguish valid independent Duke histories from malformed, +unseated, living or unsupported sources without exposing custody details. +`tests/duke-ambassador-engine.test.ts` separately checks actual native entry +consumption, the Ecaz-only beneficiary, copied effects, unchanged resources and +ordinary allowance, prior cohort opening, event/exclusive-choice guards, native +movement continuation and a subsequent separate original acquisition. Original +source and final integrated results are recorded below. + +Frozen owner integration passes **5 files, 42/42 tests**, types/lint and production +build. `tests/fixture-duke-ambassador.ts` supplies two genuine public Basic +Ecaz/Harkonnen/Guild programmes with original E3 components. Four unopened deal +positions select two real poisons; two unopened ordinary Spice positions and +original scalar/packet lotteries are recorded. Actual paid1+2 placements, reserve +shipments and an Ecaz-token acquisition precede both real poison plans. Duke +dies at deaths1. Ecaz wins the zero-dial attacker tie without losing its force or +a native leader, naturally leaving Duke as its sole revival alternative. + +Native and BG-copy sources use **72/73 accepted actions**. On turn2 an actual +Guild shipment costs1 and triggers the already placed token. Each human branch +uses two more actions: free Duke return, then explicit completion of the same +entrant's movement turn. All four existing profiles in both original positions +submit that same legal alternative and finish the entrant turn: **8 policy +continuations**, without strategy refinement. Wallets, force pools, hands and +ordinary allowances are unchanged by the return; Duke remains strength6, +deaths1, alive/set-aside and outside the five-disc roster. + +Actual GameTable and isolated entry rendering verify persistent destination +guidance and enabled/busy Duke buttons. Framework aliases/CSS stubs make this +rendering evidence, not browser/visual/deployed acceptance. The controlled +Advanced source (without Harkonnen), repeated Duke/cohort opening, non-Ecaz, +event/exclusivity, decline and separate reacquisition probes remain distinct +from the two genuine Basic sources. Full modes, other custody/module envelopes, +generic ordinary repeats/income and final assurance remain open. + +Source identity: base `c95e2948908a853289a0c9b0c09143fb41e37fa8`, frozen tree +`9848951790a5e3a314934cc32613fe2feb84716ffe56c61fd345571f9147253c`, +2,606 tracked/nonignored files. Private logs, HTML, source manifest and original +failed checks are retained outside Git under `20261024-duke-ambassador/`. +Feedback repaired only fixture/control expectations after the consumer was +connected: use the original legal first/later Storm choices, retain actual source +sector/token bindings, and finish Advanced Guild permission before awaiting the +Ambassador. The private render assertion then needed an attribute-only check +instead of matching the CSS `disabled:` utility. That runtime alone reran on the +identical source; the final aggregate links original passing tests/types/lint/ +build and the repaired runtime. Checkpoint prose/reference overlays are separately +reviewed. No saved-room/database/reset/restart/deployment operation occurred. + ## First-death own Kwisatz Haderach — 11 October 2026 The source-supported separate KH identity below now appears in Atreides' @@ -65,7 +152,7 @@ Specific identities compose as follows: - **Kwisatz Haderach:** base p.17 says a killed KH is revived like a leader, without affecting the ordinary Atreides roster threshold. This supports an Atreides KH choice under the one-leader alternative. Preserve its own activation/death record; it is not another native leader disc or a free force. [Base p.17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17) - **Auditor:** it is a CHOAM leader, so the own-leader alternative can include a dead CHOAM Auditor. Its normal early-return permission expressly consumes the ordinary leader slot, while the Ambassador's accounting is still unresolved. The published exclusions prohibit Tleilaxu foreign-ghola acquisition and Harkonnen capture. They do not prohibit CHOAM receiving its own free return. Existing repeat-death and sixth-disc cycle questions remain; this grant does not answer them. [GF9 CHOAM & Richese, p.8](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=8) -- **Duke Vidal:** E3 p.9 expressly restricts revival to Ecaz, including Ghola. An allied beneficiary does not inherit an exception. If the beneficiary is Ecaz, the revival is free under this effect, rather than charging Duke's normal five. The existing unresolved revived-disc destination and six-disc cycle issues remain exactly as documented in [Ecaz revival rules](ECAZ_REVIVAL_RULES.md); do not choose active Ecaz custody versus set-aside custody here. [E3 pp.8–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9) +- **Duke Vidal:** E3 p.9 expressly restricts revival to Ecaz, including Ghola. An allied beneficiary does not inherit an exception. If the beneficiary is Ecaz, the revival is free under this effect, rather than charging Duke's normal five. The 24 October route above applies the existing selected set-aside destination and independent Duke permission; generic cohort/repeat/income interpretations remain separately recorded in [Ecaz revival rules](ECAZ_REVIVAL_RULES.md). [E3 pp.8–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9) - **Captured or foreign-ghola leaders:** a living unavailable captive is not a dead piece in Tanks. The Ambassador does not grant a capture release or acquisition of an opponent's disc. A leader's temporary controller must not substitute for its legitimate revival owner. Exceptional concealed custody needs the existing generic unavailable projection, not an opponent-visible identity probe. Keep physical identity and death history when returning a leader. A normal revived leader is usable, remains susceptible to its Traitor, and does not become a new disc. The base Ghola FAQ allows immediate reuse in a later battle, but that card-specific clarification does not authorize resetting unrelated histories or bypassing a still-pending Ambassador parent. [Base pp.9,22](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) diff --git a/game/engine.ts b/game/engine.ts index 5796fb73..8a55da3f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -18540,7 +18540,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) { requireRule(action.forces === undefined && action.elite === undefined, 'Choose a leader or forces, not both.'); const selected = tleilaxuAmbassadorLeaderSelection(g, p, action.leader); - requireRule(selected, 'Choose an eligible first-death own leader or Kwisatz Haderach from the Tanks.'); + requireRule(selected, 'Choose an eligible own leader, Ecaz Duke or Kwisatz Haderach from the Tanks.'); let name: string; if (selected === 'kwisatz') { const kwisatz = p.kwisatz!; @@ -18548,13 +18548,21 @@ function decideAmbassador(g: Game, p: Player, action: Action) { delete kwisatz.usedAt; kwisatz.revivalCycle = Math.max(p.revivalCycle, kwisatz.revivalCycle ?? 1) + 1; name = 'Kwisatz Haderach'; + } else if (selected === DUKE_VIDAL_ID) { + const duke = g.dukeVidal!.leader; + recordEcazLeaderReturn(g, duke); + duke.dead = false; + delete duke.usedAt; + delete duke.concealed; + name = duke.name; + g.dukeVidal = consumeDuke(g.dukeVidal!); } else { recordEcazLeaderReturn(g, selected); selected.dead = false; delete selected.usedAt; name = selected.name; } - log(g, `${p.name} revived ${name} for free through the Tleilaxu Ambassador. The ordinary leader allowance is unchanged.`, + log(g, `${p.name} revived ${name} for free through the Tleilaxu Ambassador.${selected === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ''} The ordinary leader allowance is unchanged.`, { faction: 'tleilaxu', name: 'Tleilaxu Ambassador' }); finishAmbassador(g); return; diff --git a/game/reference.ts b/game/reference.ts index b97650ee..0f758299 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1522,7 +1522,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 first-death own-leader revival or up to four physical forces for free. Atreides may choose its separate first-killed Kwisatz Haderach under the base leader-revival rule; this does not add a disc to the ordinary roster, reset battle losses or spend the ordinary leader allowance. Native and Bene Gesserit copied grants share the same private eligibility. Every existing KH return advances its original cycle marker; a prior return remains outside this first-death preview. The force/leader choices are exclusive and ordinary allowance independence stays provisional. Repeat-return, shared-leader and revival-income interpretations remain separate.', + '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; ordinary allowance/income independence stays provisional and generic repeat/custody combinations remain separate.', '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. Full Ecaz starts remain disabled.', ], checklist: [ @@ -1530,34 +1530,40 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Original effects, copies, Duke acquisition, consent and token returns remain integrated. Ordinary shipment and native Guild transport distinguish future own income/BG controls from actual busy state; the existing committed-entry queue handles settlement. A legitimate fighter companion has its own child entry. Original prices, funding, payment, forces and movement remain; unsupported paired/module combinations stay partial.', - evidence: ['game/engine.ts', 'game/bot-arrival.ts'], + '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. Native shipment/Guild income and BG child/original arrival settlement remain; wider combinations stay partial.', + evidence: ['game/engine.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'], }, { area: 'Player controls', status: 'Partial', detail: - 'Existing placement, effect, consent and movement controls remain. Four actual390px native journeys use Guild transport and income cancellation, peaceful accompaniment, accepted Intrusion, and retained fighters with distinct child/original offers. Original entrant resumes actual movement after once-only payment and transfer; advisors do not create a bogus fighter offer.', + 'Existing placement, effect, consent and movement controls remain. An entitled Duke revival choice explains the alive/unclaimed shared destination before selection. Earlier four actual390px native transport/income/BG journeys remain separate evidence; original entrant movement and once-only payment are retained.', + evidence: ['components/ecaz-entry.tsx', 'components/game-table.tsx'], }, { area: 'AI', status: 'Partial', detail: - 'All four profiles retain private legal placement, effect, consent and arrival decisions. Native transport future income and ordinary future accompaniment no longer block a candidate solely as predicted busy controls. All four Ecaz policies acquire Duke after income; all four BG policies legally convert and accompany. The retained-fighter child path is separate human/scripted proof. No strategy calibration.', + 'All four existing profiles legally select the sole free Duke alternative in both original Basic native/BG-copy positions and finish the entrant turn: eight policy continuations. Earlier legal placement, consent, native income/acquisition and BG arrival paths remain separate evidence. No strategy refinement or calibration.', + evidence: ['game/bots.ts', 'tests/duke-ambassador-engine.test.ts'], }, { area: 'Documentation', status: 'Partial', detail: 'Source-bound effects, copies and Duke guides remain. Native own-arrival child-first/live-token order stays provisional. Separate native transport permission, independent purchased typed prices and own/allied marker billing preserve original sources and approvals. Immune Junction now keeps its offered tariff/payees with an explicit unused-purchase choice. Cunning opportunity scope, special-stop card return and unused-price expiry are selected interpretations; full combinations remain open.', - evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'], + evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/TLEILAXU_AMBASSADOR_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'], }, { area: 'Verification', status: 'Partial', detail: - 'Affected six-file55/55,20 original API/policy paths and four actual390px journeys pass: income allow/cancel, accompaniment and Intrusion accept/decline/cancel, legitimate fighter child, token consumption and original movement. No overflow/application browser errors. Genuine original-action fixtures do not stage played hands, wallets, armies or phase. Existing saved/deployed evidence stays historical; full acceptance remains open.', + 'Current Duke-grant5-file42/42, types/lint/build, two original Basic gameplay/rendering programmes and eight legal policies pass. Actual acquisition/death/paid Guild entry precede the free original-disc return and movement completion. Controlled Advanced/repeat/cohort/ownership/reacquisition probes and earlier native arrival/browser evidence remain separate; full mode, combination, browser/deployed and final assurance stay open.', evidence: [ + 'tests/fixture-duke-ambassador.ts', + 'tests/duke-ambassador-engine.test.ts', + 'tests/tleilaxu-ambassador-leaders.test.ts', + 'tests/tleilaxu-ambassador-engine.test.ts', 'tests/ecaz-ambassadors.test.ts', 'tests/ecaz-placement.test.ts', 'tests/ecaz-entry.test.ts', diff --git a/game/tleilaxu-ambassador-leaders.ts b/game/tleilaxu-ambassador-leaders.ts index ece343a0..b94d5ed5 100644 --- a/game/tleilaxu-ambassador-leaders.ts +++ b/game/tleilaxu-ambassador-leaders.ts @@ -1,14 +1,32 @@ 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'; -type Context = Pick; -type Beneficiary = Pick; -export type TleilaxuAmbassadorLeaderChoice = { id: string; name: string; strength: number }; +type Context = 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.faction === player.faction && leader.dead && leader.deaths === 1 && + leader.id !== DUKE_VIDAL_ID && leader.faction === player.faction && leader.dead && leader.deaths === 1 && !leader.capturedBy && !leader.gholaBy; +/** Duke's independent Ecaz-only permission retains its original death history. + * Reuse native identity/custody validation, projecting every unavailable source + * uniformly rather than exposing capture details or a profile-specific reason. */ +function sharedDuke(context: Context, player: Beneficiary): boolean { + if (player.faction !== 'ecaz' || typeof player.id !== 'string') return false; + try { + resolveEcazDukeRevival(context as EcazDukeRevivalContext, player.id); + return true; + } catch { + return false; + } +} + /** 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. */ @@ -20,7 +38,8 @@ function firstKwisatz(context: Context, player: Beneficiary): boolean { /** Return the actual own identity, never a foreign disc or a cloned physical leader. */ export function tleilaxuAmbassadorLeaderSelection( context: Context, player: Beneficiary, id: unknown, -): Leader | 'kwisatz' | null { +): 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; } @@ -31,6 +50,9 @@ export function tleilaxuAmbassadorLeaderChoices( const choices: TleilaxuAmbassadorLeaderChoice[] = []; for (const leader of player.leaders) if (ownFirstDeath(player, leader)) 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 }); return choices; } diff --git a/tests/duke-ambassador-engine.test.ts b/tests/duke-ambassador-engine.test.ts new file mode 100644 index 00000000..597b6a3b --- /dev/null +++ b/tests/duke-ambassador-engine.test.ts @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors'; +import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { botActions } from '../game/bots'; +import { actDukeAmbassador, createDukeAmbassadorFixture } from './fixture-duke-ambassador'; + +const player = (g: Game, id = 'ecaz') => g.players.find(p => p.id === id)!; +const reject = (g: Game, actor: string, action: Action) => { + const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before); +}; +/** Controlled consumer probes. Original-action source proof is separate. */ +function fixture({ advanced = false, copied = false, deaths = 1 } = {}) { + const g = createGame('DUKEAMBPROBE', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']); + g.players.push(newPlayer('entrant', 'Entrant', 'guild'), newPlayer('ally', 'Ally', 'atreides')); + Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, + active: 'entrant', order: ['entrant', 'ecaz', 'ally'], movementRemaining: ['entrant', 'ecaz', 'ally'], deck: baseDeck() }); + for (const p of g.players) { p.hand = []; p.forces = {}; p.reserves = 20; p.spice = 12; } + g.dukeVidal = createDukeVidal(); Object.assign(g.dukeVidal.leader, { dead: true, deaths, usedAt: 'imperial_basin' }); + const state = createAmbassadors(() => 0), effect = copied ? 'beneGesserit' : 'tleilaxu'; + const cohort = [effect, ...state.tokens.map(t => t.effect).filter(e => e !== effect && 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 === effect)!; + token.zone = 'placed'; token.location = 'arrakeen'; validateAmbassadors(state); + g.ecazAmbassadors = state; + return g; +} +function pass(g: Game) { + for (let n = 0; g.response && n < 30; n++) { + const actor = g.players.find(p => !g.response!.passed.includes(p.id))!; + g = applyAction(g, actor.id, { type: 'passResponse' }); + } + assert.equal(g.response, null); return g; +} +function offer(source: Game, beneficiary = 'ecaz', copied = false) { + let g = pass(applyAction(source, 'entrant', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 })); + if (g.decision?.kind === 'guildShipment') + g = pass(applyAction(g, g.decision.player, { type: 'decision', allow: true })); + assert.equal(g.pendingAmbassador?.stage, 'offer'); + const event = g.pendingAmbassador!.event; + g = applyAction(g, 'ecaz', { type: 'decision', event, trigger: true, beneficiary, choice: 'effect' }); + if (copied) { + assert.equal(g.pendingAmbassador?.stage, 'copy'); + g = applyAction(g, beneficiary, { type: 'decision', event, effect: 'tleilaxu' }); + } + assert.equal(g.pendingAmbassador?.stage, 'revival'); return g; +} +const balances = (g: Game) => g.players.map(p => ({ id: p.id, spice: p.spice, + reserves: p.reserves, tanks: p.tanks, forces: p.forces, hand: p.hand, + revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived })); +function revive(g: Game) { + return applyAction(g, 'ecaz', { type: 'decision', event: g.pendingAmbassador!.event, leader: DUKE_VIDAL_ID }); +} + +for (const advanced of [false, true]) void test(`controlled ${advanced ? 'Advanced' : 'Basic'} Ambassador returns the actual Duke alive and set aside without price or ordinary allowance`, () => { + const g = offer(fixture({ advanced })), before = structuredClone(g), own = viewGame(g, 'ecaz').ambassadorEntry!.revival!; + assert.ok(own.leaders.some(l => l.id === DUKE_VIDAL_ID)); + assert.equal(own.maximum, 0); assert.equal(viewGame(g, 'entrant').ambassadorEntry!.revival, null); + const result = revive(g); + assert.deepEqual(balances(result), balances(before)); + assert.equal(result.dukeVidal!.leader.id, DUKE_VIDAL_ID); assert.equal(result.dukeVidal!.leader.strength, 6); + assert.equal(result.dukeVidal!.leader.dead, false); assert.equal(result.dukeVidal!.leader.deaths, 1); + assert.equal(result.dukeVidal!.leader.usedAt, undefined); assert.equal(result.dukeVidal!.controller, null); + assert.equal(result.dukeVidal!.source, null); assert.equal(result.dukeVidal!.acquiredTurn, null); + assert.ok(result.players.every(p => !p.leaders.some(l => l.id === DUKE_VIDAL_ID))); + assert.equal(player(result).leaders.length, 5); assert.ok(!result.pendingRevival); + assert.equal(result.pendingAmbassador, null); assert.equal(result.active, 'entrant'); + assert.equal(player(result, 'entrant').forces['arrakeen:10'], 1); + assert.ok(result.log.some(l => /living shared disc is set aside, unclaimed/.test(l.text))); + reject(result, 'ecaz', { type: 'decision', event: before.pendingAmbassador!.event, leader: DUKE_VIDAL_ID }); + const ended = applyAction(result, 'entrant', { type: 'endMovement' }); + assert.equal(ended.movementRemaining?.includes('entrant') ?? false, false); +}); + +void test('controlled copied Tleilaxu grant keeps Duke identity and the Ecaz-only beneficiary rule', () => { + const copied = offer(fixture({ copied: true }), 'ecaz', true); + assert.equal(revive(copied).dukeVidal!.leader.dead, false); + const source = fixture(); player(source).ally = 'ally'; player(source, 'ally').ally = 'ecaz'; + const allied = offer(source, 'ally'); + assert.equal(viewGame(allied, 'ally').ambassadorEntry!.revival!.leaders.some(l => l.id === DUKE_VIDAL_ID), false); + reject(allied, 'ally', { type: 'decision', event: allied.pendingAmbassador!.event, leader: DUKE_VIDAL_ID }); + const declined = applyAction(allied, 'ally', { type: 'decision', event: allied.pendingAmbassador!.event, decline: true }); + assert.equal(declined.dukeVidal!.leader.dead, true); assert.equal(declined.pendingAmbassador, null); +}); + +void test('controlled independent Duke return preserves repeated history and a satisfied native cohort opening', () => { + const source = fixture({ deaths: 3 }); player(source).leaderRevived = true; + for (const leader of player(source).leaders.slice(0, 4)) Object.assign(leader, { dead: true, deaths: 1 }); + const g = offer(source), before = structuredClone(g), done = revive(g); + assert.equal(done.dukeVidal!.leader.deaths, 3); assert.equal(done.dukeVidal!.leader.dead, false); + assert.equal(player(done).leaderRevived, true); assert.deepEqual(balances(done), balances(before)); + assert.equal(player(done).ecazRevivalCohort?.cycle, 1); + assert.deepEqual(player(done).ecazRevivalCohort?.returned, []); + assert.equal(player(done).leaders.filter(l => l.dead).length, 4); +}); + +void test('controlled Duke grant uses the current event and cannot also return forces or act for another beneficiary', () => { + const g = offer(fixture()), event = g.pendingAmbassador!.event; + reject(g, 'ecaz', { type: 'decision', event: 'old-event', leader: DUKE_VIDAL_ID }); + reject(g, 'entrant', { type: 'decision', event, leader: DUKE_VIDAL_ID }); + reject(g, 'ecaz', { type: 'decision', event, leader: DUKE_VIDAL_ID, forces: 1 }); + const declined = applyAction(g, 'ecaz', { type: 'decision', event, decline: true }); + assert.equal(declined.dukeVidal!.leader.dead, true); assert.equal(declined.pendingAmbassador, null); +}); + +void test('controlled Duke return requires a separate original Ambassador acquisition before Ecaz controls him', () => { + const source = fixture(), token = source.ecazAmbassadors!.tokens.find(t => t.effect === 'ecaz')!; + token.zone = 'placed'; token.location = 'carthag'; validateAmbassadors(source.ecazAmbassadors!); + const revived = revive(offer(source)); assert.equal(revived.dukeVidal!.controller, null); + let arrived = pass(applyAction(revived, 'entrant', { type: 'move', from: 'arrakeen:10', amount: 1, territory: 'carthag', sector: 11 })); + assert.equal(arrived.pendingAmbassador?.token, token.id); assert.equal(arrived.pendingAmbassador?.stage, 'offer'); + const event = arrived.pendingAmbassador!.event; + arrived = applyAction(arrived, 'ecaz', { type: 'decision', event, trigger: true, beneficiary: 'ecaz', choice: 'duke' }); + assert.equal(arrived.dukeVidal!.controller, 'ecaz'); assert.equal(arrived.dukeVidal!.source, 'ecaz'); + assert.equal(arrived.dukeVidal!.leader.dead, false); assert.equal(arrived.dukeVidal!.leader.deaths, 1); + assert.ok(arrived.players.every(p => !p.leaders.some(l => l.id === DUKE_VIDAL_ID))); +}); + +void test('all four existing profiles choose a legal sole Duke return and resume the original entrant', () => { + const source = offer(fixture()); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(source, 'ecaz'); view.players.find(p => p.id === 'ecaz')!.bot = difficulty; + const action = botActions(view)[0]; assert.equal(action.leader, DUKE_VIDAL_ID); + const done = applyAction(source, 'ecaz', action); + assert.equal(done.dukeVidal!.leader.dead, false); assert.equal(done.dukeVidal!.controller, null); + assert.equal(done.active, 'entrant'); assert.equal(done.pendingAmbassador, null); + } +}); + +for (const copied of [false, true]) void test(`genuine Basic ${copied ? 'copied' : 'native'} Ambassador revives the original battle-killed Duke and resumes paid Guild entry`, () => { + const f = createDukeAmbassadorFixture({ copied }), g = f.game; + assert.equal(g.advanced, false); assert.deepEqual(g.expansions, ['ecaz']); + assert.ok(f.actions.some(step => step.action.type === 'start')); + assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0)); + assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(c => c.id), f.deal); + assert.equal(f.acquisition.after.dukeVidal!.controller, f.owner); + assert.equal(f.afterDeath.dukeVidal!.leader.dead, true); + assert.equal(f.afterDeath.dukeVidal!.leader.deaths, 1); + assert.equal(player(g).tanks, 0); assert.ok(player(g).leaders.every(l => !l.dead)); + assert.deepEqual(viewGame(g, f.owner).ambassadorEntry!.revival!.leaders.map(l => l.id), [DUKE_VIDAL_ID]); + assert.equal(viewGame(g, f.entrant).ambassadorEntry!.revival, null); + const done = actDukeAmbassador(g, f.owner, { type: 'decision', event: f.event, leader: DUKE_VIDAL_ID }); + assert.deepEqual(balances(done), balances(g)); + assert.equal(done.dukeVidal!.leader.dead, false); assert.equal(done.dukeVidal!.leader.deaths, 1); + assert.equal(done.dukeVidal!.controller, null); assert.equal(done.pendingAmbassador, null); + assert.equal(done.active, f.entrant); assert.equal(player(done, f.entrant).forces[f.location], 1); + assert.deepEqual([...done.deck, ...done.discard, ...done.players.flatMap(p => p.hand)].map(c => c.id).sort(), f.originalTreachery); + const finished = actDukeAmbassador(done, f.entrantContinuation.actor, f.entrantContinuation.action); + assert.equal(finished.movementRemaining?.includes(f.entrant) ?? false, false); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(g, f.owner); view.players.find(p => p.id === f.owner)!.bot = difficulty; + const action = botActions(view)[0]; assert.equal(action.leader, DUKE_VIDAL_ID); + const revived = actDukeAmbassador(g, f.owner, action); + assert.equal(revived.dukeVidal!.leader.dead, false); assert.equal(revived.dukeVidal!.controller, null); + assert.equal(revived.pendingAmbassador, null); assert.deepEqual(balances(revived), balances(g)); + } +}); diff --git a/tests/fixture-duke-ambassador.ts b/tests/fixture-duke-ambassador.ts new file mode 100644 index 00000000..c0f4f33c --- /dev/null +++ b/tests/fixture-duke-ambassador.ts @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type DukeAmbassadorStep = { actor: string; action: Action }; +export type DukeAmbassadorTransition = { before: Game; step: DukeAmbassadorStep; after: Game }; +export const dukeAmbassadorPlayer = (game: Game, actor = 'ecaz') => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const dukeAmbassadorClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; + +/** Select original scalar lotteries only. UUID and other byte entropy remains + * native. The optional nine values belong solely to original packet creation. */ +function withDukeAmbassadorLottery(run: () => T, packet: readonly number[] = []): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) + array[0] = cursor < packet.length ? packet[cursor++] : 0xffffffff; + else Reflect.apply(original, crypto, [array]); + return array; + }; + try { + const result = run(); assert.equal(cursor, packet.length, 'The original Ambassador packet consumes its nine selected draws'); + return result; + } finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function actDukeAmbassador(game: Game, actor: string, action: Action, + actions: DukeAmbassadorStep[] = []): Game { + const next = withDukeAmbassadorLottery(() => applyAction(game, actor, action)); + actions.push({ actor, action: structuredClone(action) }); return next; +} + +/** The owned programme supplies all Ambassador choices and sealed plans. This + * driver passes native responses, declines further paid placements and makes + * original public readiness/end-of-movement choices without optional spending. */ +export function nextDukeAmbassadorStep(game: Game): DukeAmbassadorStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + if (game.decision?.kind === 'ecazAmbassador') return null; + if (game.decision?.kind === 'ecazPlacement') + return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + if (game.status === 'setup' && game.setupStage === 'forces' && dukeAmbassadorPlayer(game).reserves === 20) + return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } }; + } + return nextStrongholdFactionsNativeStep(game); +} +export function advanceDukeAmbassador(game: Game, until: (state: Game) => boolean, + actions: DukeAmbassadorStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Duke programme ended before its next control'); + const next = nextDukeAmbassadorStep(game); + assert.ok(next, `Choose the actual Ambassador effect or sealed plan before advancing: ${JSON.stringify({ + turn: game.turn, phase: game.phase, active: game.active, decision: game.decision, + entry: game.pendingAmbassador ? { stage: game.pendingAmbassador.stage, entrant: game.pendingAmbassador.entrant, + territory: game.pendingAmbassador.territory, effect: game.pendingAmbassador.effect } : null, + })}`); + game = actDukeAmbassador(game, next.actor, next.action, actions); + } + throw Error('The original Duke Ambassador programme did not reach its finite boundary'); +} + +/** Fisher–Yates draws for the original ten-token pool. The native token IDs, + * zones and cohort are created by initializeStartingForces, never rewritten. */ +function originalPacket(copied: boolean) { + const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz'); + const supply: Exclude[] = copied + ? ['beneGesserit', 'atreides', 'choam', 'emperor', 'fremen'] + : ['tleilaxu', 'atreides', 'choam', 'emperor', 'fremen']; + const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))]; + const current = [...pool], rolls: number[] = []; + for (let i = current.length - 1; i > 0; i--) { + const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); + [current[i], current[j]] = [current[j], current[i]]; + } + assert.deepEqual(current, desired); return { rolls, supply }; +} + +/** Genuine public Basic E3 source. Two distinct original poison cards kill + * both leaders in one real Carthag battle. Ecaz is the native attacker and + * wins the zero-strength tie with dial zero, so none of its forces or native + * leaders enter the Tanks. Only the separate shared Duke needs revival. + * + * Stop BEFORE selecting the newly supported Duke grant. This fixture does not + * install that selection, a death, a token, a wallet, a turn or an arrival. */ +export function createDukeAmbassadorFixture(options: { copied?: boolean } = {}) { + const copied = options.copied ?? false, packet = originalPacket(copied); + let game = createGame('DUKEAMBASSADOR', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']); + for (const faction of ['harkonnen', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const actions: DukeAmbassadorStep[] = []; + const act = (actor: string, action: Action) => { game = actDukeAmbassador(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceDukeAmbassador(game, until, actions); }; + const transition = (step: DukeAmbassadorStep, until: (state: Game) => boolean): DukeAmbassadorTransition => { + const before = structuredClone(game); act(step.actor, step.action); reach(until); + return { before, step: structuredClone(step), after: structuredClone(game) }; + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); + assert.ok(game.players.every(player => player.hand.length === 0)); + const originalTreachery = game.deck.map(card => card.id).sort(); + const poisons = game.deck.filter(card => card.kind === 'poison').slice(0, 2); + const fillers = game.deck.filter(card => card.kind === 'worthless').slice(0, 2); + assert.equal(poisons.length, 2); assert.equal(fillers.length, 2); + const deal = [poisons[0].id, poisons[1].id, fillers[0].id, fillers[1].id]; + const dealOrder = deal.map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); + let packetCreation: DukeAmbassadorTransition | null = null; + for (let count = 0; game.status === 'setup' && count < 80; count++) { + const next = nextDukeAmbassadorStep(game); assert.ok(next); + const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' && + game.players.filter(player => player.traitorChoices.length > 0).length === 1; + if (initializesForces) { + const before = structuredClone(game); + game = withDukeAmbassadorLottery(() => applyAction(game, next.actor, next.action), packet.rolls); + actions.push(structuredClone(next)); + packetCreation = { before, step: structuredClone(next), after: structuredClone(game) }; + } else act(next.actor, next.action); + } + assert.equal(game.status, 'playing'); assert.ok(packetCreation); + const afterSetup = structuredClone(game); + const owner = 'ecaz', opponent = 'harkonnen', entrant = 'guild'; + const ecazToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!; + const revivalToken = game.ecazAmbassadors!.tokens.find(token => token.effect === (copied ? 'beneGesserit' : 'tleilaxu'))!; + assert.equal(ecazToken.zone, 'supply'); assert.equal(revivalToken.zone, 'supply'); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.effect === 'tleilaxu')!.zone, copied ? 'pool' : 'supply'); + assert.ok(dukeAmbassadorPlayer(game).hand.some(card => card.id === poisons[0].id)); + assert.ok(dukeAmbassadorPlayer(game, opponent).hand.some(card => card.id === poisons[1].id)); + // The original first two unopened land cards replace no live encounter and + // keep both turns free of a worm/alliance detour. All Spice cards are retained. + 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 === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement'); + const placements = [ + transition({ actor: owner, action: { type: 'decision', token: ecazToken.id, territory: 'arrakeen' } }, + state => !state.response && state.decision?.kind === 'ecazPlacement'), + transition({ actor: owner, action: { type: 'decision', token: revivalToken.id, territory: 'habbanya_ridge_sietch' } }, + state => !state.response && state.decision?.kind === 'ecazPlacement'), + ]; + assert.equal(placements[0].after.players.find(player => player.id === owner)!.spice, + placements[0].before.players.find(player => player.id === owner)!.spice - 1); + assert.equal(placements[1].after.players.find(player => player.id === owner)!.spice, + placements[1].before.players.find(player => player.id === owner)!.spice - 2); + act(owner, { type: 'decision', decline: true }); + reach(state => state.phase === 5 && state.active === owner && dukeAmbassadorClean(state)); + const ownShipment = transition({ actor: owner, action: { type: 'ship', amount: 1, territory: 'carthag', sector: 11 } }, dukeAmbassadorClean); + reach(state => state.phase === 5 && state.active === opponent && dukeAmbassadorClean(state)); + const acquisitionArrival = transition({ actor: opponent, action: { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 } }, + state => !state.response && state.pendingAmbassador?.stage === 'offer'); + assert.equal(game.pendingAmbassador!.token, ecazToken.id); + const acquisition = transition({ actor: owner, action: { type: 'decision', event: game.pendingAmbassador!.event, + trigger: true, beneficiary: owner, choice: 'duke' } }, dukeAmbassadorClean); + assert.equal(game.dukeVidal!.controller, owner); assert.equal(game.dukeVidal!.source, 'ecaz'); + assert.equal(game.dukeVidal!.leader.dead, false); + reach(state => state.phase === 6 && !state.battle && dukeAmbassadorClean(state)); + assert.equal(game.active, owner, 'The original first player circle gives Ecaz this battle choice'); + const beforeBattle = structuredClone(game), battleStep: DukeAmbassadorStep = { + actor: owner, action: { type: 'chooseBattle', territory: 'carthag', target: opponent }, + }; + act(battleStep.actor, battleStep.action); + reach(state => !!state.battle && dukeAmbassadorClean(state) && !state.battle.preparation && + (!state.battle.preLeader || state.battle.preLeader.closed)); + assert.equal(game.battle!.attacker, owner); + const enemyLeader = dukeAmbassadorPlayer(game, opponent).leaders.filter(leader => !leader.dead && !leader.usedAt) + .sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemyLeader); + const planSteps: DukeAmbassadorStep[] = [ + { actor: owner, action: { type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0, weapon: poisons[0].id } }, + { actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 0, support: 0, weapon: poisons[1].id } }, + ]; + for (const step of planSteps) { + act(step.actor, step.action); + reach(state => !state.response && !state.phaseOpening && !state.decision); + } + const revealed = structuredClone(game); + reach(state => !state.battle && dukeAmbassadorClean(state)); + const afterDeath = structuredClone(game); + assert.equal(game.dukeVidal!.leader.dead, true); assert.equal(game.dukeVidal!.leader.deaths, 1); + assert.equal(game.dukeVidal!.controller, null); + assert.equal(dukeAmbassadorPlayer(game).tanks, 0); + assert.ok(dukeAmbassadorPlayer(game).leaders.every(leader => !leader.dead)); + assert.equal(dukeAmbassadorPlayer(game).forces['carthag:11'], 1); + assert.equal(dukeAmbassadorPlayer(game, opponent).leaders.find(leader => leader.id === enemyLeader.id)!.dead, true); + reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && dukeAmbassadorClean(state)); + const beforeArrival = structuredClone(game); + assert.equal(dukeAmbassadorPlayer(game, entrant).shipped, false); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === revivalToken.id)!.zone, 'placed'); + const arrival = transition({ actor: entrant, action: { type: 'ship', amount: 1, + territory: 'habbanya_ridge_sietch', sector: 17 } }, + state => !state.response && state.pendingAmbassador?.stage === 'offer'); + const offer = structuredClone(game), event = game.pendingAmbassador!.event; + assert.equal(game.pendingAmbassador!.token, revivalToken.id); + const triggerStep: DukeAmbassadorStep = { actor: owner, + action: { type: 'decision', event, trigger: true, beneficiary: owner, choice: 'effect' } }; + act(triggerStep.actor, triggerStep.action); + let beforeCopy: Game | null = null, copyStep: DukeAmbassadorStep | null = null; + if (copied) { + assert.equal(game.pendingAmbassador?.stage, 'copy'); beforeCopy = structuredClone(game); + copyStep = { actor: owner, action: { type: 'decision', event, effect: 'tleilaxu' } }; + act(copyStep.actor, copyStep.action); + } + assert.equal(game.pendingAmbassador?.stage, 'revival'); + assert.equal(game.pendingAmbassador!.beneficiary, owner); assert.equal(game.pendingAmbassador!.entrant, entrant); + assert.equal(game.pendingAmbassador!.effect, 'tleilaxu'); assert.equal(game.decision?.player, owner); + assert.equal(game.active, entrant); assert.equal(game.turn, 2); + assert.equal(dukeAmbassadorPlayer(game).tanks, 0); + assert.ok(dukeAmbassadorPlayer(game).leaders.every(leader => !leader.dead)); + assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)] + .map(card => card.id).sort(), originalTreachery); + const currentGrant = structuredClone(game), ownerView = viewGame(game, owner), entrantView = viewGame(game, entrant); + return { game, lobby, setup, beforeStartingDeal, afterSetup, packetCreation, packet, deal, dealOrder, + spiceOrder, originalTreachery, placements, ownShipment, acquisitionArrival, acquisition, + beforeBattle, battleStep, planSteps, revealed, afterDeath, beforeArrival, arrival, offer, + triggerStep, beforeCopy, copyStep, currentGrant, ownerView, entrantView, + copied, advanced: false as const, owner, beneficiary: owner, opponent, entrant, + duke: DUKE_VIDAL_ID, enemyLeader: enemyLeader.id, token: revivalToken.id, acquisitionToken: ecazToken.id, + event, territory: 'habbanya_ridge_sietch', location: 'habbanya_ridge_sietch:17', + poison: { ecaz: poisons[0].id, harkonnen: poisons[1].id }, + entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as DukeAmbassadorStep, + actions, sourceActionCount: actions.length, + staging: ['Only four unopened starting-deal positions, two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the actual initial Ambassador packet during the final original traitor choice. All other scalar draws retain original Fisher–Yates order; native UUID/byte entropy remains native. Public Basic Ecaz/Harkonnen/Guild setup, paid 1+2 Ambassador placements, paid reserve shipments, actual Ecaz-token Duke acquisition and two sealed poison plans produce the first Duke death. Ecaz wins the 0–0 attacker tie with dial 0 and keeps its physical force, naturally leaving no own force or native-leader revival alternative. Next turn Guild really pays and enters the already placed Tleilaxu or BG token. Stop at the native revival grant before choosing Duke. No dealt hand, army, wallet, clock, death, alliance, token, pending reaction or receipt is installed. Advanced with this Harkonnen roster is outside this source fixture.'] }; +} +export type DukeAmbassadorFixture = ReturnType; diff --git a/tests/tleilaxu-ambassador-leaders.test.ts b/tests/tleilaxu-ambassador-leaders.test.ts new file mode 100644 index 00000000..703caaa2 --- /dev/null +++ b/tests/tleilaxu-ambassador-leaders.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { leaders } from '../game/cards'; +import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { + tleilaxuAmbassadorLeaderChoices, + tleilaxuAmbassadorLeaderSelection, +} from '../game/tleilaxu-ambassador-leaders'; + +function fixture(advanced = false, deaths = 1) { + const player = { id: 'ec', faction: 'ecaz' as const, leaders: leaders('ecaz'), battleLosses: 0 }; + const context = { + status: 'playing' as const, turn: 3, advanced, + players: [player, { id: 'other', faction: 'emperor' as const, leaders: leaders('emperor'), battleLosses: 0 }], + dukeVidal: createDukeVidal(), + }; + context.dukeVidal.leader.dead = true; + context.dukeVidal.leader.deaths = deaths; + 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', () => { + for (const advanced of [false, true]) { + const { context, player } = fixture(advanced), before = structuredClone(context); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, DUKE_VIDAL_ID), DUKE_VIDAL_ID); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player), [{ + id: DUKE_VIDAL_ID, name: 'Duke Prad Vidal', strength: 6, destination: 'setAside', + }]); + assert.deepEqual(context, before); + assert.equal(player.leaders.length, 5, 'The shared disc is never appended to the native roster'); + assert.equal(context.dukeVidal.leader.dead, true, 'Quoting a choice does not revive or transfer the disc'); + } +}); + +void test('independent Duke permission preserves repeated deaths without extending ordinary Ambassador repeat returns', () => { + for (const deaths of [2, 4]) { + const { context, player } = fixture(false, deaths); + const first = player.leaders[0], repeated = player.leaders[1]; + first.dead = true; first.deaths = 1; + repeated.dead = true; repeated.deaths = 2; + context.dukeVidal.controller = 'ec'; + context.dukeVidal.source = 'ecaz'; + context.dukeVidal.acquiredTurn = 1; + context.dukeVidal.leader.usedAt = 'carthag'; + const before = structuredClone(context); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, DUKE_VIDAL_ID), DUKE_VIDAL_ID); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, first.id), first); + assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player).map(choice => choice.id), [first.id, DUKE_VIDAL_ID]); + assert.deepEqual(context, before); + assert.equal(context.dukeVidal.leader.deaths, deaths); + } +}); + +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) { + const other = { id: 'other', faction, leaders: leaders(faction), battleLosses: 0 }; + assert.equal(tleilaxuAmbassadorLeaderSelection(context, other, DUKE_VIDAL_ID), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, other), []); + } + const absent = { ...player, id: 'absent' }; + assert.equal(tleilaxuAmbassadorLeaderSelection(context, absent, DUKE_VIDAL_ID), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, absent), []); + assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, DUKE_VIDAL_ID), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: false }, player), []); +}); + +void test('living, unavailable and malformed Duke sources uniformly produce no exceptional choice', () => { + const changes: ((value: ReturnType) => void)[] = [ + value => { value.context.dukeVidal.leader.dead = false; }, + value => { value.context.dukeVidal.leader.deaths = 0; }, + value => { value.context.dukeVidal.leader.deaths = 1.5; }, + value => { value.context.dukeVidal.leader.strength = 5; }, + value => { value.context.dukeVidal.leader.id = 'forged-disc'; }, + value => { value.context.dukeVidal.leader.capturedBy = 'other'; }, + value => { value.context.dukeVidal.leader.gholaBy = 'other'; }, + value => { value.context.dukeVidal.leader.concealed = { + captor: 'other', controller: 'ec', dead: true, deaths: 1, + }; }, + value => { value.context.dukeVidal.controller = 'absent'; }, + value => { value.context.dukeVidal.acquiredTurn = 2; }, + value => { value.player.leaders.push(structuredClone(value.context.dukeVidal.leader)); }, + value => { value.context.players[1].leaders.push(structuredClone(value.context.dukeVidal.leader)); }, + ]; + for (const change of changes) { + const value = fixture(); change(value); + const before = structuredClone(value.context); + assert.equal(tleilaxuAmbassadorLeaderSelection(value.context, value.player, DUKE_VIDAL_ID), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(value.context, value.player), []); + assert.deepEqual(value.context, before); + } + const { context, player } = fixture(); + assert.equal(tleilaxuAmbassadorLeaderSelection({ ...context, dukeVidal: undefined }, player, DUKE_VIDAL_ID), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ ...context, dukeVidal: undefined }, player), []); +}); + +void test('unsupported Advanced custody envelopes use the same no-option projection as missing source facts', () => { + const { context, player } = fixture(true); + const blocked = { ...context, players: [player, + { id: 'h', faction: 'harkonnen' as const, leaders: leaders('harkonnen') }] }; + assert.equal(tleilaxuAmbassadorLeaderSelection(blocked, player, DUKE_VIDAL_ID), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(blocked, player), []); + const incomplete = { advanced: true, status: 'playing' as const, turn: 3 }; + assert.equal(tleilaxuAmbassadorLeaderSelection(incomplete, player, DUKE_VIDAL_ID), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices(incomplete, player), []); +}); + +void test('minimal ordinary contexts retain their actual own first-death leader and reject repeated or foreign custody', () => { + const own = leaders('ecaz')[0], repeated = leaders('ecaz')[1], foreign = leaders('emperor')[0]; + const captive = leaders('ecaz')[2], ghola = leaders('ecaz')[3]; + for (const leader of [own, repeated, foreign, captive, ghola]) { leader.dead = true; leader.deaths = 1; } + repeated.deaths = 2; captive.capturedBy = 'other'; ghola.gholaBy = 'other'; + const player = { faction: 'ecaz' as const, leaders: [own, repeated, foreign, captive, ghola], battleLosses: 0 }; + assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, own.id), own); + for (const leader of [repeated, foreign, captive, ghola]) + assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, leader.id), null); + assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, 'unknown'), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: false }, player), [ + { id: own.id, name: own.name, strength: own.strength }, + ]); +}); + +void test('minimal first-KH contexts keep their existing Advanced activation and revival-cycle boundaries', () => { + const player = { faction: 'atreides' as const, leaders: [], battleLosses: 7, + kwisatz: { dead: true, revivalCycle: 1 } }; + assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: true }, player, 'kwisatz'), 'kwisatz'); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: true }, player), [ + { id: 'kwisatz', name: 'Kwisatz Haderach', strength: 2 }, + ]); + for (const unavailable of [ + { ...player, battleLosses: 6 }, + { ...player, kwisatz: { dead: false, revivalCycle: 1 } }, + { ...player, kwisatz: { dead: true, revivalCycle: 2 } }, + ]) { + assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: true }, unavailable, 'kwisatz'), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: true }, unavailable), []); + } + assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, 'kwisatz'), null); + assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: false }, player), []); + assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: true }, { + ...player, kwisatz: { dead: true }, + }, 'kwisatz'), 'kwisatz', 'Legacy first-cycle KH records retain their default'); +});