Allow Ecaz Ambassador Duke acquisition for an existing ally

This commit is contained in:
Quintine committed 2026-10-26 08:31:55 +11:00
1 parent cab6b41c67
commit 34dccdfd99
21 files changed
+965 -86

No files matched your search

@@ -0,0 +1,78 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
export function EcazDukeAcquisitionChoices({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const entry = game.ambassadorEntry;
if (
!entry ||
entry.stage !== 'offer' ||
entry.effect !== 'ecaz' ||
entry.owner !== game.me ||
game.decision?.kind !== 'ecazAmbassador' ||
game.decision.player !== game.me
)
return null;
const recipients = entry.beneficiaries.length
? entry.beneficiaries
: [{
player: entry.owner,
blocked: entry.dukeAcquisition
? entry.dukeAcquisition.blocked
: 'Duke acquisition is unavailable for this opportunity.',
}];
return (
<div className="flex flex-col gap-3" aria-label="Ecaz Ambassador Duke acquisition">
<p className="m-0 text-sm leading-6">
Acquire Duke Vidal for a listed recipient. The Ambassador returns to
your supply.
</p>
<p className="m-0 text-sm leading-6" aria-label="Provisional shared Duke tenure">
Provisional ordinary acquisition: the chosen recipient keeps Duke Vidal
until battle use, including when unused beyond this turn, unless another
rule takes him away. The separate Duke loan after a new alliance lasts
for the current turn. Only Ecaz may revive him.
</p>
{recipients.map((recipient) => {
const blocked = recipient.blocked;
const disabled = busy || !!blocked;
const reasonId = `ecaz-duke-${entry.event}-${recipient.player}-blocked`;
const name = game.players.find((player) => player.id === recipient.player)?.name ?? recipient.player;
return (
<div key={recipient.player} className="flex flex-col gap-2">
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
data-testid={`ecaz-duke-acquisition-${recipient.player}`}
disabled={disabled}
aria-describedby={blocked ? reasonId : undefined}
onClick={() => {
if (!disabled)
act({
type: 'decision',
event: entry.event,
trigger: true,
beneficiary: recipient.player,
choice: 'duke',
});
}}
>
Acquire Duke Vidal for {name}
</Button>
{blocked && <p id={reasonId} className="m-0 text-sm leading-6">{blocked}</p>}
</div>
);
})}
</div>
);
}
+2 -30
View File
@@ -13,6 +13,7 @@ import { LeaderInspector } from './leader-inspector';
import { AmbassadorInspector, AMBASSADOR_COVERAGE } from './ecaz-ambassadors';
import { AmbassadorMovement } from './ambassador-movement';
import { AmbassadorShipment } from './ambassador-shipment';
import { EcazDukeAcquisitionChoices } from './ecaz-duke-acquisition-choices';
const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
@@ -51,11 +52,6 @@ export function EcazEntry({
(choice) => !choice.blocked && selected.includes(choice.card.id),
);
const legalSelection = entry.effect === 'choam' || chosen.length === 1;
const dukeBlock =
entry.dukeAcquisition?.blocked ??
(entry.dukeAcquisition
? null
: 'Duke acquisition is unavailable for this opportunity.');
const allianceBlock =
entry.allianceOffer?.blocked ??
(entry.allianceOffer ? null : 'This alliance opportunity is unavailable.');
@@ -84,11 +80,6 @@ export function EcazEntry({
<>
{entry.effect === 'ecaz' ? (
<>
<p className="m-0 text-sm leading-6">
Acquire Duke Vidal for Ecaz. The Ambassador returns to your
supply. Ecaz keeps Duke until battle use or until Moritani takes
him.
</p>
{duke && (
<LeaderInspector
identity={{
@@ -99,26 +90,7 @@ export function EcazEntry({
}}
/>
)}
<Button
className={buttonClass}
disabled={busy || !!dukeBlock}
aria-describedby={dukeBlock ? `${id}-duke-block` : undefined}
onClick={() => {
if (!dukeBlock)
send({
trigger: true,
beneficiary: entry.owner,
choice: 'duke',
});
}}
>
Acquire Duke Vidal for Ecaz
</Button>
{dukeBlock && (
<p id={`${id}-duke-block`} className="m-0 text-sm leading-6">
{dukeBlock}
</p>
)}
<EcazDukeAcquisitionChoices game={game} act={act} busy={busy} />
<Button
className={buttonClass}
disabled={busy || !!allianceBlock}
+33 -7
View File
@@ -16,6 +16,39 @@ open mode gates.
## Current checkpoint and work
**Shared Ecaz Ambassador Duke — 26 October. Prototyped / Partial.**
The triggered token can now grant its living Duke to Ecaz's current reciprocal
ally. Per-recipient controls and all four minimal profiles use the server's
offered choices. Ordinary source `ecaz` records the granting power and keeps
unused tenure across turns until battle use; the new-alliance `ally` loan stays
turn-long. This composition is visibly provisional. Current entry/alliance,
same-recipient no-op, exceptional custody, occupied-Ecaz priority and Ecaz-only
revival guards remain. Later Nexus receipts recognize ordinary shared tenure.
Three disjoint quote/native/control lanes and root integration pass **19 files,
189/189**, types/lint/build and **four native engine/GameTable SSR/actual leaf-
callback journeys** on unchanged source. Genuine public Basic92/Advanced104-
action sources play self acquisition, turn-end retention, a real Nexus alliance
and fresh token entry. After the ally click, real battle use releases the same
disc; the alternate native-leader battle retains unused Duke into turn3. All33
original Treachery and12 Nexus cards remain. Pure/controlled supplements cover
recipient legality, stale/foreign choices, exceptional custody and Nexus receipts.
Frozen v5 report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-ecaz-duke-ally-proof-v5/`;
journeys/HTML: adjacent `20261026-ecaz-duke-ally-runtime/`.
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
Actual hook-free callbacks and SSR are not DOM/hydration or browser/mobile/
deployed acceptance. Complete rules/combinations, mode gates and final assurance
remain open. [Source and selected tenure](ECAZ_DUKE_ACQUISITION.md#existing-ally-direct-acquisition--26-october-2026).
Next concrete Basic function gap: Distrans rejects even an untouched ordinary
auction lot before the first bid/pass. Scope that pre-bid transfer under the
14 October recommended-policy authority, retaining the actual card, lot/order,
hand-limit eligibility and active-bid restrictions. See
[the timing audit](RICHESE_DISTRANS_ENGINE_AUDIT.md), `distransBlock` and
`tests/distrans-engine.test.ts`; broader special-auction timing remains separate.
**Caladan reinforcement before Face Dance — 26 October. Prototyped / Partial.**
The original winning army may now take its optional reinforcement before native
replacement. The later reveal uses the live10/11 Arrakis or3/4 Homeworld force
@@ -42,13 +75,6 @@ No clicked-control/DOM/hydration or browser/mobile/deployed acceptance is claime
Complete rules/combinations, mode gates and final assurance remain open.
[Source, policy and child scope](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#reinforcement-before-face-dance--26-october-2026).
Next concrete Basic function gap: an existing Ecaz ally still cannot receive the
triggered Ecaz Ambassador's living Duke acquisition. Printed sharing supports
that branch; scope direct-acquisition tenure separately from the new-alliance
turn loan, preserving occupied-Ecaz priority and the user's Ecaz-only revival.
See [the Ambassador audit](ECAZ_AMBASSADOR_EFFECTS_AUDIT.md),
`ambassadorEffectBlock`, `game/ecaz-duke-acquisition.ts` and `components/ecaz-entry.tsx`.
**Residual Poison after Cunning — 26 October. Prototyped / Partial.**
The printed pre-leader window now stays available after two completed nonleader
Cunning inspections. Resolve one actual random leader death/discard, retain an
+1 -1
View File
@@ -125,7 +125,7 @@ These are the remaining consequential combinations, not reasons to ask the user
- **No-Field, Face Dancer, worm and moving mobile stronghold:** the p.14 explicit No-Field/Face Dancer FAQ is about Terror. It is not an Ambassador FAQ. Ordinary committed force movement is supported by the broad entry trigger; a zero-force No-Field placement and a moving stronghold carrying forces need cause-specific classification. Record the actual arrival type rather than globally equating all `place()` calls with entry.
- **Tleilaxu Ambassador revival accounting:** the effect supplies its own free amount/leader alternative, but does not expressly say whether prior normal revivals reduce it, whether it consumes subsequent ordinary quota/free allowance, whether dead-twice cycle prerequisites are waived, or whether the leader must satisfy normal revival eligibility. A Ghola card handler hardcodes a different force quantity and income classification and cannot settle those questions. Recruits doubles the current normal free rate; do not automatically double the Ambassador's printed four. Duke's Ecaz-only restriction is explicit; an ally benefit is not an exception.
- **Independent free revival versus blocked faction advantages:** E1's Tleilaxu payment sentence and technology token rules need their own event classification. `collectRevivalIncome(..., free=true)` currently awards at most once per faction/turn; `ghola=true` awards per use. An Ambassador is not a Ghola card, so choosing that boolean solely to obtain a payout invents a rule. `techIncome` is phase-scoped and once-per-turn; an Ambassador revival during Shipment and Movement must not manufacture Axlotl income outside its printed phase. La La La/free-revival prevention and the Tleilaxu special prevention must be checked by their own wording, not inherited from normal quote plumbing. No specific E3 combination answer was found. [E1 pp.6–7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=6)
- **Ecaz effect assigned to an existing ally:** the general benefit-sharing sentence and the specific new-alliance Duke loan can be read differently for direct acquisition by an already allied faction. The existing same-turn-loan state must not be reused silently for every shared effect. New-alliance rejection/no-eligible-Duke, and an impossible effect invoked only to recycle a token, also require an explicit no-op consumption policy.
- **Ecaz effect assigned to an existing ally:** the [26 October selected direct-acquisition composition](ECAZ_DUKE_ACQUISITION.md#existing-ally-direct-acquisition--26-october-2026) now connects printed sharing. Ordinary source `ecaz` lasts until battle use; the distinct new-alliance `ally` loan remains turn-long. This is an explicit provisional tenure choice under the recommended-policy authority. Same-recipient no-op remains rejected before token consumption; wider no-op/alliance edge policies stay separate.
- **Post-trigger cancellation:** the p.16 entry is placement-specific. Do not add a compulsory generic `ecazAmbassadorEffect` Karama response for all eleven effects. A separately invoked native advantage, such as Harkonnen's additional card or BG's advisor, can still have its own existing cancellation window. Such child windows must return to the Ambassador continuation.
## Acceptance cases for a complete slice
+61 -13
View File
@@ -1,8 +1,50 @@
# Ecaz Ambassador: direct Duke acquisition
Integrated 7 September 2026. Ecaz can now choose **Acquire Duke Vidal for Ecaz** when an eligible entrant reaches its reusable Ambassador in supported development games. The server transfers the existing shared disc, returns the token to supply and resumes the entrant’s remaining actions. There is no additional confirmation or new generic Ambassador cancellation response.
Integrated 7 September 2026; existing-ally extension 26 October 2026. Ecaz can choose **Acquire Duke Vidal** for itself or its current reciprocal ally when an eligible entrant reaches its reusable Ambassador in supported development games. The server transfers the existing shared disc, returns the token to supply and resumes the entrant’s remaining actions. There is no additional confirmation or new generic Ambassador cancellation response.
The direct acquisition and tenure follow the publisher’s [Ecaz & Moritani rules, pp.7–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf). Fresh indexed publisher retrieval rechecked eligibility outside Tanks, captured or ghola custody. The prior [Duke source audit](DUKE_VIDAL_RULES.md) and [remaining effects readiness review](ECAZ_REMAINING_EFFECTS_READINESS.md) record the separate unresolved combinations. This implements the direct self-acquisition choice; it does not certify the token’s alliance and loan alternatives, full Ecaz or a complete expansion game.
The direct acquisition and tenure follow the publisher’s [Ecaz & Moritani rules, pp.7–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf). Fresh indexed publisher retrieval rechecked eligibility outside Tanks, captured or ghola custody. The prior [Duke source audit](DUKE_VIDAL_RULES.md) and [remaining effects readiness review](ECAZ_REMAINING_EFFECTS_READINESS.md) record the separate unresolved combinations. The current direct choice supports self or the existing reciprocal ally as recorded below; this does not certify full Ecaz or a complete expansion game.
## Existing-ally direct acquisition — 26 October 2026
The triggered reusable Ecaz Ambassador may now grant its direct acquisition to
the current reciprocal ally instead of Ecaz. Publisher E3 p8's general benefit
sharing and the authorized Advanced physical30,F support the recipient. Under
14 October choose-recommended authority, select ordinary direct-acquisition
tenure until battle use, including unused control across turns; this is
provisional composition, not a publisher worked example. It is distinct from
the newly formed alliance's optional Basic/automatic Advanced turn-long loan.
Reuse source `ecaz` for the same direct power and physical disc; changing the
recipient needs no new saved source kind. Qualification is rechecked at this
actual token entry. A living Ecaz-held or set-aside disc can go to the ally;
choosing its existing controller still rejects a no-op. Death history and
`usedAt` remain, and no roster or Traitor gains a duplicate. Captive, concealed,
Ghola, dead, duplicated-disc and existing table/profile exclusions remain.
Occupied-Ecaz priority is checked before acquisition; only Ecaz may revive Duke.
There is no standing gift/transfer action outside a fresh eligible entry.
A living current-turn Nexus grant may likewise become this ordinary acquisition;
the original card stays spent. Later Ecaz Cunning receipts recognize ordinary
source `ecaz` as its granting power even when a former ally holds that tenure,
including its captured underlay. Native Nexus tenure still requires the native
Ecaz controller; no custody or mode gate is relaxed.
The private owner sees separate offered beneficiaries and per-recipient reasons.
All four minimal profiles use the first legal direct recipient after any
eligible alliance alternative. Generic exceptional-custody reasons retain the
existing projection boundary. Broader combinations and final assurance remain.
Frozen verification passes **19 files,189/189**, types/lint/build and four native
engine/GameTable SSR/actual hook-free choice-callback journeys. Genuine public
Basic92/Advanced104-action sources play self acquisition, unused turn-end
retention, Nexus alliance and fresh entry. The actual ally grant is followed
either by Duke battle use/release or a native-leader battle and retained unused
Duke into turn3. All33 original Treachery and12 Nexus cards remain. The quote,
engine, controls and original acquisition/bot/Nexus tests cover the related
guards; controlled receipt supplements are not complete custody campaigns.
Private report/logs: `/home/quintine/.local/share/dune/checkpoints/20261026-ecaz-duke-ally-proof-v5/`;
journeys/HTML: adjacent `20261026-ecaz-duke-ally-runtime/`.
No saved-room/D1/server or recovery operation occurs. Browser/mobile/deployed
acceptance and full combinations remain due; SSR/callbacks do not prove hydration.
## No-Skills native Nexus scope extension — 15 October 2026
@@ -69,19 +111,19 @@ grant wording. Types pass; no broad suite or deployed acceptance was run.
`game/ecaz-duke-acquisition.ts` quotes a detached next `DukeState` using the existing custody helper. It requires a current seated Ecaz owner and the existing canonical shared disc. It preserves identity, death history and `usedAt`; it never appends a leader to a native roster, adds a Traitor card, manufactures a replacement disc, samples randomness or grants new battle-use permissions. Starting-force initialization remains the normal disc-creation boundary.
The actual entry event, turn/phase, decision owner, physical placed Ecaz token, entrant eligibility and explicit `choice:'duke'`/self-beneficiary are checked before the token commits. Current native-roster duplication rejects through the same availability descriptor. An existing ally does not prevent Ecaz from acquiring for itself. An eligible Duke controlled by Moritani transfers as the same physical disc. The controller/source/turn update and token return occur in one authoritative action, followed by the existing Ambassador suffix.
The actual entry event, turn/phase, decision owner, physical placed Ecaz token, entrant eligibility and explicit `choice:'duke'`/current self-or-reciprocal-ally beneficiary are checked before the token commits. Current native-roster duplication rejects through the same availability descriptor. An existing ally does not prevent Ecaz from acquiring for itself. An eligible Duke controlled by Moritani transfers as the same physical disc. The controller/source/turn update and token return occur in one authoritative action, followed by the existing Ambassador suffix.
The reusable token does not belong to the five-token random cohort. Its trigger leaves that cohort unchanged and never replenishes it. The chronicle distinguishes reusable return from ordinary set-aside tokens and records previous Duke control, the new tenure and resumption of the entrant’s actions. Successful acquisition emits an automatic notice using Ecaz’s faction color and the existing configurable 1.5-second notice queue.
The existing leader lifecycle supplies Duke to Ecaz’s battle-plan options and consumes temporary custody after battle use. Death retains the same disc in Tanks. Unused source-Ecaz control persists across turns, while a later qualifying Moritani acquisition still takes the same disc. Existing Advanced-Harkonnen and exceptional custody limits remain in force; this feature does not order the unresolved capture/return combination.
The existing leader lifecycle supplies Duke to the chosen recipient’s battle-plan options and consumes temporary custody after battle use. Death retains the same disc in Tanks. Unused source-Ecaz control persists across turns, while a later qualifying Moritani acquisition still takes the same disc. Existing Advanced-Harkonnen and exceptional custody limits remain in force; this feature does not order the unresolved capture/return combination.
Already controlling Duke, acquisition for an ally, alliance formation and loans remain explicitly unfinished. The same-owner restriction is a development boundary, not an asserted printed prohibition on a no-op trigger. Unsupported or unavailable commands preserve the placed token; declining the opportunity also preserves Duke and resumes the entrant.
Same-recipient acquisition remains a rejected no-op before token consumption; the self case retains its historical development boundary rather than asserting a printed prohibition. Existing-ally acquisition is now connected as above, and Basic/Advanced new-alliance choices retain their distinct earlier prototypes. Unsupported or unavailable commands preserve the placed token; declining the opportunity also preserves Duke and resumes the entrant.
## Controls, privacy and AI
Only the actual owner’s Ecaz-token offer contains the `dukeAcquisition` descriptor. The entry panel renders its explicit acquisition button, current availability reason, portrait inspector and leave-in-place alternative. Other seats cannot submit the choice. Exceptional or concealed custody yields a generic unavailable reason rather than exposing a captor/ghola identity; concealed-state private variants cannot vary the availability descriptor. Advanced-Harkonnen configurations are uniformly unavailable before inspecting that custody.
Only the actual owner’s Ecaz-token offer contains the `dukeAcquisition` descriptor and per-recipient benefit choices. The entry panel renders the offered acquisition buttons and availability reasons, one portrait inspector and the leave-in-place alternative. The self descriptor remains a compatibility fallback when the benefit list is absent. Other seats cannot submit the choice. Exceptional or concealed custody yields a generic unavailable reason rather than exposing a captor/ghola identity. Existing profile restrictions are checked before that custody; the dated no-Skills Harkonnen extensions above supersede the original blanket table restriction.
All four AI profiles use the owner’s projected descriptor to acquire or decline. They do not inspect raw Duke custody, foreign hands, spice or Traitor cards. Enabling the Ecaz handler does not add Ecaz to BG’s random-token copy choices.
All four AI profiles use the owner’s projected choices to acquire for a legal recipient or decline, after any eligible alliance alternative. They do not inspect raw Duke custody, foreign hands, spice or Traitor cards. Enabling the Ecaz handler does not add Ecaz to BG’s random-token copy choices.
The complete Ambassador gameplay explanation remains readable in the token inspector, including printed alternatives that are not implemented yet. Separate coverage copy identifies those limits. Duke’s shared inspector and internal reference now distinguish unused Ecaz tenure from Moritani end-turn expiry. The public Ambassador inventory is collapsed by default behind a native keyboard-accessible summary showing supply/placed counts; expanding it retains every zone and inspector. Actual entry and placement choices remain open.
@@ -89,13 +131,16 @@ The complete Ambassador gameplay explanation remains readable in the token inspe
| Facet | Evidence | Remaining |
| --- | --- | --- |
| Implementation | Shared acquisition quote, atomic real entry, token return, original movement/worm suffix, same-disc battle/turn lifecycle | Alliance/loan alternatives, same-owner no-op, Duke revival/capture and full Occupy |
| Player controls | Explicit action, availability text, complete token guide, Duke portrait, private-owner descriptor, compact inventory | Mobile viewport acceptance of this new panel and broader faction journeys |
| AI | Eleven tests cover all four policies in Basic/Advanced, with/without ally, unavailable choices and privacy | Strength calibration and full expansion games |
| Implementation | Shared self/ally quote, atomic real entry, token return, original movement/worm suffix, same-disc battle/turn lifecycle | Same-recipient no-op policy and wider revival/capture/occupation combinations |
| Player controls | Per-recipient actions/reasons, persistent tenure guidance, complete token guide, Duke portrait, private-owner descriptor, compact inventory | Browser/mobile acceptance of the ally choices and broader faction journeys |
| AI | All four minimal policies use projected legal recipients in Basic/Advanced; related bot regressions pass | Strength calibration and full expansion games |
| Documentation | Internal Ecaz/Duke topics, five-facet checklist, Ambassador guide and shared-disc support metadata updated | Complete expansion reference and component inventory |
| Verification | Thirteen pure/engine, eleven AI and four production-room SQLite tests; actual browser acquisition and subsequent battle | Every legal expansion/module combination and all nested arrival interactions |
| Verification | Current 19-file189/189 union and four native SSR/callback journeys; historical self-acquisition results below are separately dated | Every legal expansion/module combination and all nested arrival interactions |
## Validation results
## Historical validation results — 7 September 2026
The following counts and artifacts describe the original self-acquisition
checkpoint, not verification of the 26 October ally extension.
**2,474 rules/client/component +224 multiplayer tests pass: 2,698 total.** The new slice adds 24 rules/AI and four persistence tests. Full logs: `/tmp/dune-ecaz-duke-rules-final.log` (74.44 seconds), `/tmp/dune-ecaz-duke-multiplayer-final.log` (24.74 seconds). Final typecheck, lint and production build pass in `/tmp/dune-ecaz-duke-{type,lint,build}-final.log`. A final build followed the dedicated Duke inspector’s support-text correction.
@@ -105,7 +150,10 @@ Production SQL tests reauthenticate through freshly loaded room code, race two i
The unchanged `/tmp/dune-sapho-base-fullgames.ts` public-start Basic runner completed **20/20 games**, counts 2–6 and all four homogeneous difficulty levels, seed 20261017: **10,450 accepted actions, zero rejected candidates/stalls and 476 JSON round trips**, 65.48 seconds. `/tmp/dune-ecaz-duke-fullgames-final.json` records unchanged game-source hashes during and after execution; combined fingerprint `1c5199b54891e91ac7fbcdbd71a18c545c2bc45a10b67cfa9a777278266ae93c`. This is a shared-engine/AI regression sample, not coverage of the gated Ecaz feature in full games.
## Browser playtest and preservation
## Historical browser playtest and preservation — 7 September 2026
This is the original self-acquisition playtest. Its room/database counts and
server session are historical; no hourly maintenance or restart is now scheduled.
Existing isolated QA room `8S3MRDEK` was backed up at v86, preserving both authenticated seat IDs. A conserved Advanced Ecaz/Moritani position stages an already Moritani-controlled Duke, a placed reusable Ecaz Ambassador and Ecaz forces in Arrakeen. The actual Moritani shipment of two forces creates the Ecaz offer at v87. Browser acquisition reaches v88 with the same disc now controlled by Ecaz, the token in supply, five random companions unchanged and the entrant’s paid shipment/counters intact. Refresh preserves the result.
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 26 October 2026 — Existing-ally Ecaz Ambassador Duke acquisition
**Prototyped / Partial.** Connect the printed benefit-sharing branch to the
current reciprocal ally at a fresh Ecaz-token entry. Reuse ordinary source
`ecaz` as the granting power with until-battle tenure, including unused control
across turns. Keep the distinct new-alliance `ally` loan turn-long; this shared
composition is explicitly provisional under 14 October authority. Per-recipient
private controls, authoritative blockers and all four minimal profiles are
connected. Preserve same-disc identity/history, occupied-Ecaz priority, Ecaz-only
revival, exceptional custody and table/profile restrictions. Later Ecaz Cunning
receipts admit ordinary shared tenure without changing native Nexus tenure.
Frozen **19-file189/189**, types/lint/build and **four native engine/GameTable
SSR/actual hook-free callback journeys** pass. Genuine public Basic92/Advanced104-
action sources earn self acquisition, unused turn-end retention, Nexus alliance
and fresh entry with Ecaz holding Duke. The actual ally choice resumes the
entrant; a real Duke battle releases the same disc, while the alternate native-
leader battle retains unused Duke into turn3. All33 physical Treachery and12
Nexus cards remain. Pure/control supplements cover recipient/custody guards,
projection/busy/foreign behavior and current living/captured Nexus receipts.
Feedback repaired obsolete self-only bot expectations, test array intersections,
one now-valid ordinary-source receipt mutation and an unused fixture import.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-ecaz-duke-ally-proof-v5/`;
journeys/HTML: adjacent `20261026-ecaz-duke-ally-runtime/`.
No saved-room/D1/server, reset/restart, deployment or recovery operation. SSR and
leaf callbacks do not claim DOM/hydration or browser/mobile/deployed acceptance.
Complete rules/combinations, mode gates and final assurance remain open.
[Source and selected tenure](ECAZ_DUKE_ACQUISITION.md#existing-ally-direct-acquisition--26-october-2026).
## 26 October 2026 — Caladan reinforcement before Face Dance
**Prototyped / Partial.** Select optional original-winner reinforcement before
+13
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Existing-ally Ecaz Ambassador Duke acquisition — 26 October 2026
Connect printed Ambassador benefit sharing (publisher E3 p8, authorized
Advanced physical30,F) to the current reciprocal ally. Under 14 October
choose-recommended authority, use ordinary direct-acquisition tenure until battle
use, including unused persistence, rather than the new-alliance turn loan.
Existing source `ecaz` identifies that direct power for either recipient; no new
source kind or standing transfer action is added. Fresh entry/token/current
alliance, same-recipient no-op, physical custody, occupied-Ecaz priority and
Ecaz-only revival remain checked. This tenure composition is visibly provisional,
not a publisher worked example or complete mode admission.
[Source and execution](ECAZ_DUKE_ACQUISITION.md#existing-ally-direct-acquisition--26-october-2026).
## Caladan reinforcement before Face Dance — 26 October 2026
Under the 14 October choose-recommended authority, grant the original winning
+6 -3
View File
@@ -2003,7 +2003,9 @@ function policyActions(g: GameView): Action[] {
return [{ ...action, forces, elite: Math.min(elite, forces) }];
}
if (entry.stage === 'offer') {
if (entry.effect === 'ecaz')
if (entry.effect === 'ecaz') {
const recipient = entry.beneficiaries.find(option => !option.blocked)?.player ??
(entry.dukeAcquisition && !entry.dukeAcquisition.blocked ? entry.owner : null);
return [
entry.allianceOffer && !entry.allianceOffer.blocked
? {
@@ -2012,15 +2014,16 @@ function policyActions(g: GameView): Action[] {
beneficiary: entry.owner,
choice: 'alliance',
}
: entry.dukeAcquisition && !entry.dukeAcquisition.blocked
: recipient
? {
...action,
trigger: true,
beneficiary: entry.owner,
beneficiary: recipient,
choice: 'duke',
}
: { ...action, decline: true },
];
}
const available = entry.beneficiaries.filter(
(option) =>
!option.blocked &&
+4 -2
View File
@@ -1,6 +1,8 @@
import type { Leader } from './cards';
export const DUKE_VIDAL_ID = 'duke-vidal';
/** Granting power, rather than controller faction: ordinary Ecaz acquisition
* may belong to its existing ally and still differs from a new-alliance loan. */
export type DukeSource = 'moritani' | 'ecaz' | 'ecazNexus' | 'ally' | 'homeworldEcaz';
export type DukeState = {
leader: Leader;
@@ -291,7 +293,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
'At the end of Shipment and Movement, Moritani gains the living Duke when its forces face battles in at least two different strongholds, excluding battles involving Ecaz. This checks the battles about to occur, rather than past battle results or several opponents in one stronghold.',
'Moritani may use Duke Vidal for one battle. An unused living, uncaptured Duke is set aside at the end of the turn; Moritani must qualify again on a later turn to acquire him again.',
'Karama may prevent Moritani from acquiring Duke Vidal. It does not strip him from Moritani once already controlled and does not cancel his ordinary battle use.',
'The Ecaz Ambassador may acquire Duke Vidal when he is not dead, captured or a ghola. Ecaz keeps him until battle use or until Moritani takes him. The Ambassador’s separate alliance choice can permit a loan to the faction that has just allied with Ecaz for that turn.',
'A triggered Ecaz Ambassador may acquire the living Duke outside captured or Ghola custody for Ecaz or its current reciprocal ally. The chosen recipient keeps him until battle use or another rule takes him away, including unused control across turns. This shared tenure is provisional. The separate newly formed alliance can instead grant a loan for the current turn.',
'Occupied Ecaz reserves the living Duke for its occupier or reciprocal ally. A sole recipient receives him automatically; an allied occupier chooses at an otherwise quiet boundary before either Battle Plan seals. The provisional grant permits one battle and expires unused at turn end; occupation loss ends its own tenure without restoring an old holder. Only Ecaz may revive him.',
'Battle death sends the same disc to the Tanks and preserves its death history. Only Ecaz may revive Duke Vidal, including when using the Ghola Treachery Card. His printed revival price is five spice, distinct from his battle strength of six, and this revival does not require other leaders to be dead.',
'Ecaz can begin ordinary leader revival when five of its leaders are in the Tanks, counting Duke Vidal, even if it still has another living leader. These special rules do not create an extra native leader or an extra Traitor card.',
@@ -304,7 +306,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
ambassadorAcquisition: 'ecaz-self-implemented',
ambassadorAlliance: 'consent-implemented',
ambassadorNewAllyLoan: 'basic-prototype',
ambassadorAllyAcquisition: 'unresolved',
ambassadorAllyAcquisition: 'ordinary-tenure-prototype',
allyLoanReturn: 'basic-set-aside-provisional',
sourceAudit: 'docs/DUKE_VIDAL_RULES.md',
}),
+25 -10
View File
@@ -4,11 +4,11 @@ import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
import { independentDiscoveryProfile, nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
export type EcazDukeContext = Partial<FactionModuleProfile> & {
export type EcazDukeContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
status: Game['status'];
turn: number;
advanced: boolean;
players: readonly { id: string; faction: FactionId }[];
players: readonly { id: string; faction: FactionId; ally?: string | null }[];
dukeVidal?: DukeState;
};
type Failure = 'context' | 'custody' | 'table' | 'alreadyControlled';
@@ -34,14 +34,17 @@ const tableBlock =
'Duke Vidal acquisition with Advanced Harkonnen is still being implemented.';
const alreadyBlock =
'Acquiring Duke Vidal when Ecaz already controls him is still being implemented.';
const allyAlreadyBlock = 'The chosen beneficiary already controls Duke Vidal.';
const unavailable = 'Duke Vidal is unavailable for this acquisition.';
/** Direct Ecaz self-acquisition only. The caller owns the actual entry event,
* physical Ambassador and explicit choice; this quote never creates a disc,
* commits the token, grants an ally loan, or changes leader battle eligibility. */
/** Ordinary Ambassador acquisition for Ecaz or its current reciprocal ally.
* Both use source Ecaz's until-battle tenure, not the new-alliance turn loan.
* The caller owns the actual entry event, physical Ambassador, occupied-Ecaz
* priority and explicit choice. No disc or new battle permission is created. */
export function quoteEcazDukeAcquisition(
g: EcazDukeContext,
ownerId: string,
recipientId: string = ownerId,
): DukeState {
requireDuke(
g.status === 'playing' &&
@@ -50,11 +53,20 @@ export function quoteEcazDukeAcquisition(
typeof g.advanced === 'boolean' &&
Array.isArray(g.players) &&
new Set(g.players.map((p) => p.id)).size === g.players.length &&
g.players.filter((p) => p.faction === 'ecaz').length === 1 &&
g.players.filter((p) => p.id === ownerId && p.faction === 'ecaz')
.length === 1,
'This acquisition needs the current seated Ecaz owner.',
'context',
);
const owner = g.players.find((p) => p.id === ownerId)!;
const recipient = g.players.find((p) => p.id === recipientId);
requireDuke(
recipient && (recipientId === ownerId ||
(owner.ally === recipientId && recipient.ally === ownerId)),
'Choose Ecaz or its current reciprocal ally for this acquisition.',
'context',
);
requireDuke(
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
(!!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
@@ -86,7 +98,8 @@ export function quoteEcazDukeAcquisition(
// This explicit unfinished case is public controller information. Check it
// before private capture/ghola details so its projected reason cannot vary
// with an undisclosed custody marker.
requireDuke(duke.controller !== ownerId, alreadyBlock, 'alreadyControlled');
requireDuke(duke.controller !== recipientId,
recipientId === ownerId ? alreadyBlock : allyAlreadyBlock, 'alreadyControlled');
requireDuke(
duke.controller === null
? duke.acquiredTurn === null && duke.source === null
@@ -94,7 +107,7 @@ export function quoteEcazDukeAcquisition(
whole(duke.acquiredTurn) &&
duke.acquiredTurn > 0 &&
duke.acquiredTurn <= g.turn &&
['ecaz', 'moritani', 'ally'].includes(duke.source!),
['ecaz', 'ecazNexus', 'moritani', 'ally'].includes(duke.source!),
'The shared Duke Vidal disc has invalid current custody.',
);
requireDuke(
@@ -103,21 +116,23 @@ export function quoteEcazDukeAcquisition(
);
// acquireDuke clones every existing leader field, including usedAt and death
// history; retaining those fields does not permit a second battle use.
return acquireDuke(duke, ownerId, g.turn, 'ecaz');
return acquireDuke(duke, recipientId, g.turn, 'ecaz');
}
/** An owner-offer descriptor may be publicly compared. Exceptional custody
* never produces a distinct reason that discloses a hidden captured/ghola ID. */
export function ecazDukeAcquisitionBlock(
g: EcazDukeContext,
ownerId: string,
recipientId: string = ownerId,
): string | null {
try {
quoteEcazDukeAcquisition(g, ownerId);
quoteEcazDukeAcquisition(g, ownerId, recipientId);
return null;
} catch (error) {
if (error instanceof EcazDukeAcquisitionError) {
if (error.reason === 'table') return tableBlock;
if (error.reason === 'alreadyControlled') return alreadyBlock;
if (error.reason === 'alreadyControlled')
return recipientId === ownerId ? alreadyBlock : allyAlreadyBlock;
}
return unavailable;
}
+6 -7
View File
@@ -18562,11 +18562,10 @@ function ambassadorEffectBlock(
return 'This Ambassador effect is still being implemented.';
if (effect === 'ecaz') {
const owner = byFaction(g, 'ecaz');
if (!owner || beneficiary.id !== owner.id)
return 'Assigning the Ecaz Ambassador effect to an ally is still being implemented.';
if (!owner) return 'Duke Vidal is unavailable for this acquisition.';
if (g.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID)))
return 'Duke Vidal is unavailable for this acquisition.';
return occupiedEcazDukePriority(g) ?? ecazDukeAcquisitionBlock(g, owner.id);
return occupiedEcazDukePriority(g) ?? ecazDukeAcquisitionBlock(g, owner.id, beneficiary.id);
}
if (
effect === 'ixians' &&
@@ -20924,8 +20923,8 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
let acquiredDuke: DukeState | null = null;
if (token.effect === 'ecaz') {
requireRule(
action.choice === 'duke' && beneficiary.id === owner.id,
'Choose the Ecaz Ambassador’s direct acquisition of Duke Vidal for Ecaz.',
action.choice === 'duke',
'Choose the Ecaz Ambassador’s direct acquisition of Duke Vidal for its beneficiary.',
);
requireRule(
canTriggerAmbassador({
@@ -20940,7 +20939,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
);
try {
requireRule(!occupiedEcazDukePriority(g), occupiedEcazDukePriority(g) ?? 'Duke Vidal is reserved by occupied Ecaz.');
acquiredDuke = quoteEcazDukeAcquisition(g, owner.id);
acquiredDuke = quoteEcazDukeAcquisition(g, owner.id, beneficiary.id);
} catch (error) {
if (error instanceof EcazDukeAcquisitionError)
throw new RuleError(error.message);
@@ -20959,7 +20958,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
g.dukeVidal = acquiredDuke;
log(
g,
`${owner.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. Ecaz keeps the shared Duke until battle use or Moritani acquisition; the entrant’s remaining actions resume.`,
`${beneficiary.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. ${beneficiary.name} keeps the shared Duke until battle use or another rule takes him away; the entrant’s remaining actions resume.`,
{ faction: owner.faction, name: 'Ecaz Ambassador — Duke Vidal' },
);
finishAmbassador(g);
+2 -2
View File
@@ -297,7 +297,7 @@ export function validateNexusEcazDuke(
(before.source === 'moritani' &&
(g.players.find(player => player.id === before.controller)?.faction !== 'moritani' ||
before.acquiredTurn !== receipt.turn)) ||
((before.source === 'ecaz' || before.source === 'ecazNexus') &&
(before.source === 'ecazNexus' &&
g.players.find(player => player.id === before.controller)?.faction !== 'ecaz') ||
(before.source === 'ecazNexus' && before.acquiredTurn !== receipt.turn)) ||
before.controller === receipt.owner ||
@@ -345,7 +345,7 @@ function validateCapturedReceipt(g: Game, receipt: CapturedReceipt, index: numbe
: !controller || !turn(before.acquiredTurn) || before.acquiredTurn > receipt.turn ||
!['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(before.source as string) ||
(before.source === 'moritani' && controller.faction !== 'moritani') ||
((before.source === 'ecaz' || before.source === 'ecazNexus') && controller.faction !== 'ecaz')) ||
(before.source === 'ecazNexus' && controller.faction !== 'ecaz')) ||
!(hidden === null || (object(hidden) &&
keys(hidden, 'captor,controller,dead,deaths,usedAt') &&
hidden.captor === leader.capturedBy && hidden.dead === false &&
+7 -2
View File
@@ -1651,7 +1651,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The first placement costs one spice, the second two, and so on that turn. A declaration opens a Karama response before spice is paid or the token moves. Canceling stops the remaining placement opportunity for that turn; previous placements and payments remain.',
'Finish Ambassador placement advances the phase. On a later turn the price begins at one again and the earlier placement block expires. Pending declarations survive reload without charging twice.',
'Destroyed tokens return to supply; triggered regular tokens are set aside, Bene Gesserit is permanently removed, and replenishment waits for all five random tokens. Storm/explosion destruction remains separate. The reusable Ecaz token returns to supply after acquisition or alliance proposal. Basic has an optional new-ally Duke loan; the supplied Advanced p30 automatically grants an available Duke after accepted consent.',
'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke. The token returns to supply and its companions are unchanged. Unused Ecaz control persists until battle use or Moritani acquisition. Basic optional and Advanced automatic new-ally grants are connected; standing ally assignment and wider captured-loan combinations remain for refinement.',
'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke for itself or its current reciprocal ally. The token returns to supply and its companions are unchanged. Direct acquisition persists unused until battle use or Moritani acquisition; that shared tenure is provisional. It is distinct from the optional Basic or automatic Advanced new-alliance turn loan. A fresh token entry is required, with no standing transfer action.',
'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.',
'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary shipment and native Guild transport settle their own income, BG Intrusion and accompaniment controls before queued entry, without replaying payment or forces. Actual busy controls and unsupported paired or module combinations remain guarded.',
'Accompaniment joining retained BG fighters creates a distinct eligible child entry before the original shipper. Declining the child leaves the live token for the original entry; consuming it does not create a token to offer again. Advisors remain exempt. This existing child-first, live-token composition is explicitly provisional, not a publisher priority ruling.',
@@ -1730,6 +1730,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/guild-ambassador-income-arrival.test.ts',
'tests/bg-ambassador-arrival.test.ts',
'tests/ecaz-duke-acquisition.test.ts',
'tests/ecaz-duke-ally-quote.test.ts',
'tests/ecaz-duke-ally-engine.test.ts',
'tests/ecaz-duke-ally-controls.test.ts',
'tests/fixture-ecaz-duke-ally.ts',
'components/ecaz-duke-acquisition-choices.tsx',
'tests/ecaz-duke-bots.test.ts',
'tests/ecaz-duke-recovery.test.ts',
'tests/ecaz-alliance.test.ts',
@@ -3358,7 +3363,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Duke Vidal is separate from both factions’ five ordinary leaders. His Ecaz identity does not mean Ecaz currently controls him. Inspect the shared disc to see its current controller, whether it is set aside, or whether it is in the Tanks.',
'At the end of Shipping and Movement, Moritani gains Vidal if it faces battles in at least two strongholds, excluding battles involving Ecaz, and Vidal is not in the Tanks. The printed rule takes him from the current controller. Moritani has him for one battle; it may acquire him again on a later turn by meeting the condition again. At turn end, set him aside unless he is in the Tanks or captured.',
'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.',
'Ecaz acquires Vidal through its Ambassador outside Tanks/captured/ghola custody. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
'Ecaz may acquire Vidal through a triggered Ambassador for itself or its current reciprocal ally outside Tanks/captured/ghola custody. Ordinary acquisition lasts until battle use, including unused control across turns; this shared tenure is provisional. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
'Ordinary Ecaz returns now use an explicit provisional native cohort. Five actual Tanked discs including Duke, or the existing unavailable-native-roster exception, opens it; completed native IDs preserve the remainder and later openings. A late first death can join a later cohort. Independent Duke deaths do not set or hold its ordinary number, and a real Duke resurrection preserves an already satisfied opening. Original native prices and one ordinary leader slot remain.',
'An unallied native Ecaz holder may spend its actual Ecaz Nexus at a quiet boundary in any phase to take Duke for the current turn, including original captured custody or the set-aside disc in Tanks. Finish committed interactions first. One battle or unused turn expiry sets him aside. Nexus liberation permanently clears captivity; it does not return him to a former captor. Actual Ecaz Ghola returns him alive and set aside before a separate Cunning play.',
+8 -1
View File
@@ -286,10 +286,17 @@ void test('explicit Duke choice/owner/event and eligibility are checked before t
reject(g, 'ec', { ...action, event: 'stale' });
reject(g, 'ec', { ...action, choice: undefined });
reject(g, 'ec', { ...action, choice: 'loan' });
reject(g, 'ec', { ...action, beneficiary: 'al' });
const allied = reload(g);
p(allied, 'ec').ally = 'al';
p(allied, 'al').ally = 'ec';
reject(allied, 'ec', { ...action, beneficiary: 'al' });
const shared = applyAction(allied, 'ec', { ...action, beneficiary: 'al' });
assert.equal(shared.dukeVidal!.controller, 'al');
assert.equal(shared.dukeVidal!.source, 'ecaz');
assert.deepEqual(shared.players, allied.players);
const staleAlly = reload(allied);
p(staleAlly, 'al').ally = null;
reject(staleAlly, 'ec', { ...action, beneficiary: 'al' });
for (const mutate of [
(b: Game) => {
delete b.dukeVidal;
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { EcazDukeAcquisitionChoices } from '../components/ecaz-duke-acquisition-choices';
import { createDukeVidal } from '../game/duke-vidal';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
},
});
const { EcazEntry } = await import('../components/ecaz-entry');
aliases.deregister();
// Controlled projections supplement the separate native acquisition sources.
// Only the public view is changed: no played alliance, Duke custody, Ambassador
// token, force, hand or entry is installed in a Game. React renders the stateful
// wrapper; only the hook-free choices leaf is called directly for callbacks.
function projected(advanced = false): GameView {
const game = createGame('ECAZDUKEALLYCONTROLS', newPlayer('e', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
joinGame(game, newPlayer('a', 'Atreides ally', 'atreides'));
joinGame(game, newPlayer('i', 'Emperor entrant', 'emperor'));
const view = viewGame(game, 'e');
view.status = 'playing'; view.phase = 5; view.turn = 2;
view.decision = { kind: 'ecazAmbassador', player: 'e' };
view.dukeVidal = createDukeVidal();
view.ambassadorEntry = {
event: 'controlled-ecaz-duke-entry', owner: 'e', entrant: 'i',
territory: 'arrakeen', stage: 'offer', effect: 'ecaz', beneficiary: null,
noField: null, shipment: null, movement: null,
allianceOffer: { blocked: 'The entrant is already allied.' },
dukeAcquisition: { blocked: null }, dukeLoan: null,
beneficiaries: [{ player: 'e', blocked: null }, { player: 'a', blocked: null }],
copies: [], cards: [], revival: null,
};
return view;
}
function markup(game: GameView, busy = false, wrapper = false) {
return renderToStaticMarkup(createElement(wrapper ? EcazEntry : EcazDukeAcquisitionChoices, {
game, busy, act() {},
}));
}
function button(html: string, recipient: string) {
const found = html.match(new RegExp(`<button\\b(?=[^>]*data-testid="ecaz-duke-acquisition-${recipient}")[^>]*>[\\s\\S]*?<\\/button>`))?.[0];
assert.ok(found, `Duke choice for ${recipient}`); return found;
}
function click(node: ReactNode, recipient: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode; onClick?: () => void; 'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === `ecaz-duke-acquisition-${recipient}` && child.props.onClick) {
child.props.onClick(); return true;
}
if (click(child.props.children, recipient)) return true;
}
return false;
}
function choose(game: GameView, recipient: string, busy = false): Action[] {
const actions: Action[] = [];
click(EcazDukeAcquisitionChoices({ game, busy, act(action) { actions.push(action); } }), recipient);
return actions;
}
void test('Ecaz Duke choices allow the projected existing ally when self acquisition is blocked', () => {
for (const advanced of [false, true]) {
const game = projected(advanced), entry = game.ambassadorEntry!;
entry.dukeAcquisition!.blocked = 'Ecaz already controls Duke Vidal.';
entry.beneficiaries[0].blocked = 'Ecaz already controls Duke Vidal.';
const html = markup(game);
assert.match(button(html, 'e'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'a'), /\sdisabled=/);
assert.match(html, /Ecaz already controls Duke Vidal/);
assert.match(html, /Acquire Duke Vidal for Atreides ally/);
assert.deepEqual(choose(game, 'e'), []);
assert.deepEqual(choose(game, 'a'), [{
type: 'decision', event: entry.event, trigger: true, beneficiary: 'a', choice: 'duke',
}]);
}
});
void test('each offered Duke recipient sends exactly its own event-bound acquisition action', () => {
const game = projected(), before = structuredClone(game);
for (const recipient of ['e', 'a']) {
assert.deepEqual(choose(game, recipient), [{
type: 'decision', event: 'controlled-ecaz-duke-entry', trigger: true,
beneficiary: recipient, choice: 'duke',
}]);
assert.doesNotMatch(button(markup(game), recipient), /\sdisabled=/);
}
assert.deepEqual(choose(game, 'i'), [], 'No unoffered entrant choice is manufactured');
assert.deepEqual(game, before);
});
void test('busy and per-recipient blocks suppress actual Duke callbacks while reasons remain visible', () => {
const game = projected();
assert.deepEqual(choose(game, 'e', true), []); assert.deepEqual(choose(game, 'a', true), []);
const busy = markup(game, true);
assert.match(button(busy, 'e'), /\sdisabled=/); assert.match(button(busy, 'a'), /\sdisabled=/);
game.ambassadorEntry!.beneficiaries[1].blocked = 'The chosen beneficiary already controls Duke Vidal.';
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.match(html, /The chosen beneficiary already controls Duke Vidal/);
assert.match(button(html, 'a'), /\sdisabled=/);
assert.match(button(html, 'a'), /aria-describedby="ecaz-duke-controlled-ecaz-duke-entry-a-blocked"/);
assert.doesNotMatch(button(html, 'e'), /\sdisabled=/);
assert.deepEqual(choose(game, 'a'), []);
}
});
void test('an empty beneficiary list retains only the old self descriptor and its unavailable reason', () => {
const game = projected(), entry = game.ambassadorEntry!;
entry.beneficiaries = [];
assert.match(markup(game), /Acquire Duke Vidal for Ecaz/);
assert.doesNotMatch(markup(game), /Acquire Duke Vidal for Atreides ally/);
assert.deepEqual(choose(game, 'e'), [{
type: 'decision', event: entry.event, trigger: true, beneficiary: 'e', choice: 'duke',
}]);
assert.deepEqual(choose(game, 'a'), []);
entry.dukeAcquisition!.blocked = 'Duke Vidal is unavailable for this acquisition.';
assert.match(markup(game), /Duke Vidal is unavailable for this acquisition/);
assert.deepEqual(choose(game, 'e'), []);
entry.dukeAcquisition = null;
assert.match(markup(game), /Duke acquisition is unavailable for this opportunity/);
assert.deepEqual(choose(game, 'e'), []);
});
void test('waiting foreign and closed entry views never expose a standalone Duke transfer', () => {
for (const change of ['absent', 'viewer', 'owner', 'actor', 'decision', 'stage', 'effect'] as const) {
const game = projected();
if (change === 'absent') game.ambassadorEntry = null;
if (change === 'viewer') game.me = 'a';
if (change === 'owner') game.ambassadorEntry!.owner = 'i';
if (change === 'actor') game.decision!.player = 'i';
if (change === 'decision') game.decision = null;
if (change === 'stage') game.ambassadorEntry!.stage = 'loan';
if (change === 'effect') game.ambassadorEntry!.effect = 'fremen';
assert.equal(markup(game), '', change);
assert.deepEqual(choose(game, 'e'), [], change); assert.deepEqual(choose(game, 'a'), [], change);
}
const waiting = projected(); waiting.decision!.player = 'i';
assert.equal(markup(waiting, false, true), '');
});
void test('ordinary Duke tenure guidance stays readable and distinguishes the new-alliance turn loan', () => {
const game = projected();
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.match(html, /Provisional ordinary acquisition/);
assert.match(html, /until battle use, including when unused beyond this turn/);
assert.match(html, /The separate Duke loan after a new alliance lasts for the current turn/);
assert.match(html, /Only Ecaz may revive him/);
assert.match(html, /The Ambassador returns to your supply/);
assert.doesNotMatch(html, /Confirm|Acknowledge|countdown|once per turn/);
}
});
void test('the React-rendered entry retains one Duke inspector and its original alliance and loan controls', () => {
const game = projected();
game.ambassadorEntry!.beneficiaries[0].blocked = 'The self choice is blocked.';
const html = markup(game, false, true);
assert.equal((html.match(/aria-label="Inspect leader: Duke Prad Vidal"/g) ?? []).length, 1);
assert.match(html, /Acquire Duke Vidal for Atreides ally/);
assert.match(html, /Offer alliance to Emperor entrant/);
assert.match(html, /The entrant is already allied/);
assert.match(html, /Leave Ambassador in place/);
assert.doesNotMatch(button(html, 'a'), /\sdisabled=/);
game.ambassadorEntry!.stage = 'loan';
const loan = markup(game, false, true);
assert.match(loan, /Lend Duke Vidal to the new ally/);
assert.match(loan, /Continue without lending Duke/);
assert.match(loan, /unused living Duke is set aside at turn end/);
assert.doesNotMatch(loan, /ecaz-duke-acquisition-|Provisional ordinary acquisition/);
});
void test('Duke recipient controls use only public names and authoritative beneficiary reasons', () => {
const game = projected();
Object.defineProperty(game.ambassadorEntry!, 'dukeAcquisition', { get() { throw Error('Read the compatibility self descriptor despite offered beneficiaries'); } });
Object.defineProperty(game, 'dukeVidal', { get() { throw Error('Read Duke custody to recreate projected eligibility'); } });
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'forces'])
Object.defineProperty(player, field, { get() { throw Error(`Duke choices read private or custody field ${field}`); } });
assert.match(markup(game), /Acquire Duke Vidal for Atreides ally/);
assert.deepEqual(choose(game, 'a'), [{
type: 'decision', event: 'controlled-ecaz-duke-entry', trigger: true, beneficiary: 'a', choice: 'duke',
}]);
});
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actEcazDukeAlly, advanceEcazDukeAlly, battleEcazDukeAlly, createEcazDukeAllyFixture,
ecazDukeAllyClean, ecazDukeAllyInventory, ecazDukeAllyPhysical,
ecazDukeAllyPlayer as player, finishEcazDukeAlly, grantEcazDukeAlly,
} from './fixture-ecaz-duke-ally';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => actEcazDukeAlly(game, actor, action));
assert.deepEqual(game, before);
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native already-allied Ecaz Ambassador transfers its held ordinary Duke to the ally`, () => {
const f = createEcazDukeAllyFixture({ advanced });
assert.equal(f.game.status, 'playing');
assert.equal(f.game.turn, 2);
assert.equal(f.game.phase, 5);
assert.equal(f.game.active, f.entrant);
assert.equal(f.game.pendingAmbassador!.stage, 'offer');
assert.equal(f.game.pendingAmbassador!.owner, f.owner);
assert.equal(f.game.pendingAmbassador!.entrant, f.entrant);
assert.equal(f.game.pendingAmbassador!.token, f.token);
assert.equal(f.game.pendingAmbassador!.territory, 'carthag');
assert.equal(f.game.response ?? null, null);
assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.battle, null);
assert.equal(player(f.game, f.owner).ally, f.ally);
assert.equal(player(f.game, f.ally).ally, f.owner);
assert.equal(player(f.game, f.entrant).ally, null);
assert.deepEqual([f.afterSelf.dukeVidal!.controller, f.afterSelf.dukeVidal!.source, f.afterSelf.dukeVidal!.acquiredTurn],
[f.owner, 'ecaz', 1]);
assert.equal(f.afterMentat.dukeVidal!.controller, f.owner);
assert.deepEqual([f.game.dukeVidal!.controller, f.game.dukeVidal!.source, f.game.dukeVidal!.acquiredTurn],
[f.owner, 'ecaz', 1]);
assert.equal(f.placements.length, 2);
assert.ok(f.placements.every(placement => placement.action.token === f.token));
assert.equal(f.shipments.length, 3);
assert.equal(f.shipments[0].step.action.territory, 'habbanya_ridge_sietch');
assert.equal(f.shipments[1].step.actor, f.ally);
assert.equal(f.shipments[2].step.actor, f.entrant);
assert.ok(f.alliance.actions.some(step => step.actor === f.owner && step.action.type === 'alliance'));
assert.ok(f.alliance.actions.some(step => step.actor === f.ally && step.action.type === 'alliance'));
const offer = viewGame(f.game, f.owner).ambassadorEntry;
assert.ok(offer);
assert.ok(offer.beneficiaries!.find(target => target.player === f.owner)!.blocked);
assert.equal(offer.beneficiaries!.find(target => target.player === f.ally)!.blocked, null);
assert.equal(player(f.game, f.ally).forces['carthag:11'], 1);
assert.equal(player(f.game, f.entrant).forces['carthag:11'], 1);
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(ecazDukeAllyInventory(f.game), f.originalInventory);
let game = grantEcazDukeAlly(f);
game = finishEcazDukeAlly(f, game);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[f.ally, 'ecaz', 2]);
assert.deepEqual(game.dukeVidal!.leader, f.originalDuke.leader);
assert.ok(game.log.filter(entry => entry.text.includes('acquired Duke Prad Vidal')).at(-1)!.text
.startsWith(`${player(game, f.ally).name} acquired Duke Prad Vidal`));
assert.deepEqual(ecazDukeAllyPhysical(game), f.originalPhysical,
'Only the existing shared disc controller changes after the already-paid native entry');
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'supply');
assert.deepEqual(game.ecazAmbassadors!.cohort, f.cohort);
assert.deepEqual(ecazDukeAllyInventory(game), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} native existing-ally ordinary Duke fights once and releases the same living shared disc`, () => {
const f = createEcazDukeAllyFixture({ advanced });
const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
const beforeRosters = granted.players.map(seat => ({ id: seat.id, leaders: seat.leaders.map(leader => leader.id) }));
const fought = battleEcazDukeAlly(f, granted);
assert.equal(fought.beforePlans.dukeVidal!.controller, f.ally);
assert.equal(fought.beforePlans.dukeVidal!.source, 'ecaz');
assert.equal(fought.plans[0].actor, f.ally);
assert.equal(fought.plans[0].action.leader, f.duke);
assert.equal(fought.sealed.battle!.plans[f.ally].leader, f.duke);
assert.equal(fought.game.battle, null);
assert.equal(fought.game.lastBattleContext!.winner, f.ally);
assert.equal(fought.game.lastBattleContext!.territory, 'carthag');
assert.deepEqual([fought.game.dukeVidal!.controller, fought.game.dukeVidal!.source, fought.game.dukeVidal!.acquiredTurn],
[null, null, null]);
assert.equal(fought.game.dukeVidal!.leader.id, f.duke);
assert.equal(fought.game.dukeVidal!.leader.dead, false);
assert.equal(fought.game.dukeVidal!.leader.deaths, f.originalDuke.leader.deaths);
assert.equal(player(fought.game, f.ally).forces['carthag:11'], 1);
assert.equal(player(fought.game, f.opponent).forces['carthag:11'] ?? 0, 0);
assert.deepEqual(fought.game.players.map(seat => ({ id: seat.id, leaders: seat.leaders.map(leader => leader.id) })), beforeRosters);
assert.deepEqual(ecazDukeAllyInventory(fought.game), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} unused existing-ally direct acquisition persists through a real native battle and next Mentat`, () => {
const f = createEcazDukeAllyFixture({ advanced });
const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
const fought = battleEcazDukeAlly(f, granted, [], { useDuke: false });
assert.notEqual(fought.plans[0].action.leader, f.duke);
assert.deepEqual(fought.game.dukeVidal, granted.dukeVidal);
const nextTurn = advanceEcazDukeAlly(fought.game, state => state.turn === 3 && state.phase === 1 && ecazDukeAllyClean(state));
assert.equal(nextTurn.turn, 3);
assert.deepEqual([nextTurn.dukeVidal!.controller, nextTurn.dukeVidal!.source, nextTurn.dukeVidal!.acquiredTurn],
[f.ally, 'ecaz', 2]);
assert.deepEqual(nextTurn.dukeVidal!.leader, granted.dukeVidal!.leader);
assert.equal(player(nextTurn, f.owner).ally, f.ally);
assert.equal(player(nextTurn, f.ally).ally, f.owner);
assert.deepEqual(ecazDukeAllyInventory(nextTurn), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} live Ecaz Duke entry rejects foreign, stale, self-held and nonally beneficiaries before consuming its token`, () => {
const f = createEcazDukeAllyFixture({ advanced });
rejected(f.game, f.ally, f.action);
rejected(f.game, f.entrant, f.action);
rejected(f.game, f.owner, { ...f.action, event: 'stale' });
rejected(f.game, f.owner, { ...f.action, beneficiary: f.owner });
rejected(f.game, f.owner, { ...f.action, beneficiary: f.entrant });
rejected(f.game, f.owner, { ...f.action, beneficiary: 'guild' });
assert.equal(player(f.beforeSelf, f.owner).ally, null);
assert.equal(player(f.beforeSelf, f.ally).ally, null);
rejected(f.beforeSelf, f.owner, { ...f.action, event: f.beforeSelf.pendingAmbassador!.event });
assert.deepEqual(f.game.dukeVidal, f.originalDuke);
assert.deepEqual(f.game.pendingAmbassador, f.originalParent);
assert.deepEqual(ecazDukeAllyPhysical(f.game), f.originalPhysical);
assert.equal(f.game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'placed');
const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
rejected(granted, f.owner, f.action);
assert.deepEqual(ecazDukeAllyInventory(granted), f.originalInventory);
});
}
void test('four minimal owner profiles choose the actual existing ally when Ecaz already holds the shared Duke', () => {
for (const advanced of [false, true]) {
const f = createEcazDukeAllyFixture({ advanced });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.owner);
view.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const action = botActions(view)[0];
assert.deepEqual(action, f.action, `${advanced}/${difficulty}`);
const game = finishEcazDukeAlly(f, actEcazDukeAlly(structuredClone(f.game), f.owner, action));
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[f.ally, 'ecaz', 2]);
assert.deepEqual(game.dukeVidal!.leader, f.originalDuke.leader);
assert.deepEqual(ecazDukeAllyPhysical(game), f.originalPhysical);
assert.deepEqual(ecazDukeAllyInventory(game), f.originalInventory);
}
}
});
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { newPlayer, type Game } from '../game/engine';
import { acquireDuke, consumeDuke, createDukeVidal, expireDuke } from '../game/duke-vidal';
import {
quoteEcazDukeAcquisition as quote, ecazDukeAcquisitionBlock as block,
EcazDukeAcquisitionError, type EcazDukeContext,
} from '../game/ecaz-duke-acquisition';
type Position = Omit<EcazDukeContext, 'players'> & { players: Game['players'] };
/** Controlled pure-rule inputs only: no native setup, acquisition, alliance or
* battle chronology is claimed. The canonical shared disc is never copied to
* one of the four native leader rosters. */
function position(advanced = false): Position {
const players = [newPlayer('ec', 'Ecaz', 'ecaz'), newPlayer('al', 'Ally', 'atreides'),
newPlayer('g', 'Guild', 'guild'), newPlayer('m', 'Moritani', 'moritani')];
players[0].ally = 'al'; players[1].ally = 'ec';
return { status: 'playing', turn: 2, advanced, expansions: ['ecaz'], players, dukeVidal: createDukeVidal() };
}
const seat = (g: Position, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; };
function rejected(g: Position, owner = 'ec', recipient = 'al') {
const before = structuredClone(g); assert.throws(() => quote(g, owner, recipient));
assert.equal(block(g, owner, recipient), 'Duke Vidal is unavailable for this acquisition.');
assert.deepEqual(g, before);
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`${band} ordinary Ecaz Duke quote preserves the existing default self acquisition`, () => {
const g = position(advanced), before = structuredClone(g);
const own = quote(g, 'ec');
assert.deepEqual(own, quote(g, 'ec', 'ec'));
assert.deepEqual(own, { leader: g.dukeVidal!.leader, controller: 'ec', acquiredTurn: 2, source: 'ecaz' });
assert.equal(block(g, 'ec'), null); assert.equal(block(g, 'ec', 'ec'), null); assert.deepEqual(g, before);
const legacy: EcazDukeContext = { ...g, players: g.players.map(({ id, faction }) => ({ id, faction })) };
assert.deepEqual(quote(legacy, 'ec'), own, 'Old self-only context callers need no ally property');
assert.equal(block(legacy, 'ec', 'al'), 'Duke Vidal is unavailable for this acquisition.');
});
void test(`${band} a reciprocal ally receives the same living or set-aside Duke with ordinary until-battle tenure`, () => {
for (const prior of ['setAside', 'ecaz', 'ecazNexus', 'moritani', 'ally'] as const) {
const g = position(advanced);
if (prior !== 'setAside')
g.dukeVidal = acquireDuke(g.dukeVidal!, prior === 'ecaz' || prior === 'ecazNexus' ? 'ec' : 'm',
prior === 'ecazNexus' ? g.turn : 1, prior);
g.dukeVidal!.leader.deaths = 2; g.dukeVidal!.leader.usedAt = 'arrakeen';
const before = structuredClone(g), shared = quote(g, 'ec', 'al');
assert.equal(shared.controller, 'al'); assert.equal(shared.source, 'ecaz'); assert.equal(shared.acquiredTurn, g.turn);
assert.deepEqual(shared.leader, g.dukeVidal!.leader); assert.equal(shared.leader.faction, 'ecaz');
assert.equal(shared.leader.deaths, 2); assert.equal(shared.leader.usedAt, 'arrakeen');
assert.equal(block(g, 'ec', 'al'), null); assert.deepEqual(g, before);
assert.notEqual(shared, g.dukeVidal); assert.notEqual(shared.leader, g.dukeVidal!.leader);
assert.deepEqual(expireDuke(shared, g.turn + 1), shared, 'Unused ordinary acquisition does not inherit the turn-loan expiry');
const loan = acquireDuke(createDukeVidal(), 'al', g.turn, 'ally');
assert.equal(expireDuke(loan, g.turn).controller, null, 'New-alliance loan remains a distinct turn-limited source');
const used = consumeDuke(shared); assert.equal(used.controller, null); assert.equal(used.source, null);
assert.equal(used.leader.usedAt, 'arrakeen'); assert.equal(used.leader.deaths, 2);
shared.leader.deaths = 99; assert.deepEqual(g, before, 'Mutating the detached quote cannot mutate a physical source disc');
assert.deepEqual(g.players.map(p => p.leaders), before.players.map(p => p.leaders));
}
});
void test(`${band} Ecaz-held Duke may transfer to its ally while same-recipient acquisition remains a no-op refusal`, () => {
const g = position(advanced); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ec', 1, 'ecaz');
const oldReason = block(g, 'ec');
assert.equal(oldReason, 'Acquiring Duke Vidal when Ecaz already controls him is still being implemented.');
assert.throws(() => quote(g, 'ec'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'alreadyControlled');
const shared = quote(g, 'ec', 'al'); assert.equal(shared.controller, 'al'); assert.equal(shared.source, 'ecaz');
g.dukeVidal = shared; const before = structuredClone(g);
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
assert.throws(() => quote(g, 'ec', 'al'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'alreadyControlled');
assert.deepEqual(g, before);
// Public same-controller descriptors do not vary with a private capture or
// Ghola marker. They confer no permission to transfer that custody.
g.dukeVidal.leader.capturedBy = 'g';
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
rejected(g, 'ec', 'ec');
delete g.dukeVidal.leader.capturedBy; g.dukeVidal.leader.gholaBy = 'g';
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
rejected(g, 'ec', 'ec');
});
}
void test('shared ordinary Duke acquisition rechecks the current unique Ecaz owner and reciprocal recipient', () => {
for (const change of [
(g: Position) => { seat(g, 'ec').ally = null; },
(g: Position) => { seat(g, 'al').ally = null; },
(g: Position) => { seat(g, 'ec').ally = 'g'; },
(g: Position) => { seat(g, 'al').ally = 'g'; },
(g: Position) => { g.players = g.players.filter(p => p.id !== 'al'); },
(g: Position) => { g.players.push(newPlayer('al', 'Duplicate recipient', 'emperor')); },
(g: Position) => { g.players.push(newPlayer('secondEcaz', 'Duplicate native owner', 'ecaz')); },
(g: Position) => { g.status = 'setup'; },
(g: Position) => { g.turn = 0; },
]) { const g = position(); change(g); rejected(g); }
const g = position(); rejected(g, 'al', 'ec'); rejected(g, 'ec', 'g'); rejected(g, 'ec', 'missing');
const earlier = quote(g, 'ec', 'al'); seat(g, 'al').ally = null; rejected(g);
assert.equal(earlier.controller, 'al', 'An earlier detached quote cannot authorize a now-broken alliance');
});
void test('shared ordinary Duke quote blocks exceptional physical custody with one generic public reason and supplies no revival', () => {
for (const advanced of [false, true]) for (const change of [
(g: Position) => { delete g.dukeVidal; },
(g: Position) => { g.dukeVidal!.leader.dead = true; },
(g: Position) => { g.dukeVidal!.leader.capturedBy = 'g'; },
(g: Position) => { g.dukeVidal!.leader.gholaBy = 'g'; },
(g: Position) => { g.dukeVidal!.leader.concealed = { captor: 'g', controller: 'ec', dead: false, deaths: 0 }; },
(g: Position) => { g.dukeVidal!.captureVisibility = { controller: 'ec', acquiredTurn: 1, source: 'ecaz' }; },
(g: Position) => { g.dukeVidal!.leader.id = 'replacement-disc'; },
(g: Position) => { g.dukeVidal!.leader.faction = 'atreides'; },
(g: Position) => { g.dukeVidal!.leader.strength = 5; },
(g: Position) => { g.dukeVidal!.leader.deaths = -1; },
(g: Position) => { g.dukeVidal!.controller = 'absent'; },
(g: Position) => { g.dukeVidal = acquireDuke(g.dukeVidal!, 'm', 3, 'moritani'); },
]) {
const g = position(advanced); change(g); rejected(g);
assert.equal(g.players.flatMap(p => p.leaders).some(leader => leader.id === 'duke-vidal'), false);
}
});
void test('sharing ordinary acquisition preserves the existing Advanced Harkonnen table boundary and admitted no-Skills profile', () => {
const g = position(true); seat(g, 'g').faction = 'harkonnen';
const reason = block(g, 'ec', 'al'); assert.match(reason!, /Advanced Harkonnen/);
assert.throws(() => quote(g, 'ec', 'al'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'table');
g.dukeVidal!.leader.capturedBy = 'g'; assert.equal(block(g, 'ec', 'al'), reason);
delete g.dukeVidal!.leader.capturedBy;
const basic = structuredClone(g); basic.advanced = false; assert.equal(block(basic, 'ec', 'al'), null);
g.ecazTreachery = true; g.discoveryEnabled = true;
assert.equal(quote(g, 'ec', 'al').controller, 'al'); assert.equal(block(g, 'ec', 'al'), null);
g.leaderSkills = {}; assert.equal(block(g, 'ec', 'al'), reason);
});
+19 -7
View File
@@ -148,12 +148,6 @@ const unavailable: [string, (g: Game) => void][] = [
g.dukeVidal!.leader.deaths = 1;
},
],
[
'already controlled by Ecaz',
(g) => {
g.dukeVidal = acquireDuke(g.dukeVidal!, 'ec', 1, 'ecaz');
},
],
[
'Advanced Harkonnen table remains gated',
(g) => {
@@ -199,7 +193,23 @@ void test('only the actual owner gets the choice, and bots cannot acquire from a
assert.deepEqual(botActions(botView(g, level, id)), []);
}
});
void test('all profiles rely on the server descriptor, with safe decline for a missing descriptor', () => {
void test('all profiles share an Ecaz-held Duke with its offered existing ally', () => {
const state = fixture(false, true);
state.dukeVidal = acquireDuke(state.dukeVidal!, 'ec', 1, 'ecaz');
const g = enter(state);
for (const level of DIFFICULTIES) {
const selected = action(g, level);
assert.deepEqual(selected, { type: 'decision', event: g.pendingAmbassador!.event,
trigger: true, beneficiary: 'al', choice: 'duke' });
const done = applyAction(g, 'ec', selected);
assert.equal(done.dukeVidal!.controller, 'al');
assert.equal(done.dukeVidal!.source, 'ecaz');
assert.deepEqual(done.players, g.players);
assert.equal(done.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!.zone, 'supply');
assert.deepEqual(done.ecazAmbassadors!.cohort, g.ecazAmbassadors!.cohort);
}
});
void test('all profiles rely on offered recipient descriptors, with safe decline when all are missing', () => {
const g = enter(fixture(false, true));
for (const level of DIFFICULTIES) {
const view = botView(g, level);
@@ -210,6 +220,8 @@ void test('all profiles rely on the server descriptor, with safe decline for a m
});
assert.equal(botActions(view)[0].choice, 'duke');
view.ambassadorEntry!.dukeAcquisition = null;
assert.equal(botActions(view)[0].choice, 'duke');
view.ambassadorEntry!.beneficiaries = [];
assert.deepEqual(botActions(view), [
{ type: 'decision', event: g.pendingAmbassador!.event, decline: true },
]);
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { validateNexusCards, NEXUS_FACTIONS } from '../game/nexus-cards';
import { actDukeAmbassador, dukeAmbassadorClean, dukeAmbassadorPlayer,
nextDukeAmbassadorStep } from './fixture-duke-ambassador';
import { advisorPreparationInventory } from './fixture-advisor-preparation';
export type EcazDukeAllyStep = { actor: string; action: Action };
export const actEcazDukeAlly = actDukeAmbassador;
export const ecazDukeAllyClean = dukeAmbassadorClean;
export const ecazDukeAllyPlayer = dukeAmbassadorPlayer;
export function ecazDukeAllyInventory(game: Game) {
const treachery = advisorPreparationInventory(game);
assert.equal(treachery.length, 33, 'The source retains the original base deck without selecting E3 Treachery');
assert.ok(game.nexusCards?.cards);
validateNexusCards(game.nexusCards.cards, game.players);
const cards = game.nexusCards.cards, nexus = [...cards.deck, ...cards.discard,
...Object.values(cards.hands).filter(card => card !== null)].sort();
assert.deepEqual(nexus, [...NEXUS_FACTIONS].sort());
assert.ok(game.players.every(player => !player.leaders.some(leader => leader.id === DUKE_VIDAL_ID)));
assert.ok(!(game.traitorReserve ?? []).includes(DUKE_VIDAL_ID));
assert.ok(game.players.every(player => ![...player.traitors, ...player.traitorChoices].includes(DUKE_VIDAL_ID)));
return { treachery, nexus };
}
export function ecazDukeAllyPhysical(game: Game) {
return { active: game.active, movementRemaining: [...(game.movementRemaining ?? [])],
players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: structuredClone(player.forces),
reserves: player.reserves, tanks: player.tanks, elites: structuredClone(player.elites),
shipped: player.shipped, moved: player.moved })) };
}
export function nextEcazDukeAllyStep(game: Game): EcazDukeAllyStep | null {
if (ecazDukeAllyClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
return nextDukeAmbassadorStep(game);
}
export function advanceEcazDukeAlly(game: Game, until: (state: Game) => boolean,
actions: EcazDukeAllyStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native existing-ally Duke source ended before its owned boundary');
const step = nextEcazDukeAllyStep(game);
assert.ok(step, `Choose the actual ${game.turn}/${game.phase}/${game.decision?.kind} before continuing`);
game = actEcazDukeAlly(game, step.actor, step.action, actions);
}
throw Error('The native existing-ally Duke source did not reach its finite boundary');
}
function orderSpice(game: Game, names: readonly string[]) {
const cards = names.map(name => {
const at = game.spiceDeck.findIndex(card => name === 'worm'
? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
: 'territory' in card && !card.discovery && card.territory === name);
assert.ok(at >= 0, `The original ${name} Spice Card must remain unopened`);
return game.spiceDeck.splice(at, 1)[0];
});
game.spiceDeck.unshift(...cards);
}
/** Native self-acquisition is deliberately left unused through Mentat. A real
* next-turn Nexus forms the alliance before the reusable token is placed and
* a different nonally entry earns the existing-ally beneficiary offer. */
export function createEcazDukeAllyFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false;
let game = createGame('ECAZDUKEALLY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
for (const faction of ['atreides', 'emperor', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const actions: EcazDukeAllyStep[] = [];
const act = (actor: string, action: Action) => { game = actEcazDukeAlly(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceEcazDukeAlly(game, until, actions); };
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const initial = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), originalInventory = ecazDukeAllyInventory(game);
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz');
assert.ok(token && token.zone === 'supply');
const cohort = [...game.ecazAmbassadors!.cohort];
orderSpice(game, ['broken_land', ...(advanced ? ['south_mesa'] : [])]);
const placements: { before: Game; action: Action; after: Game }[] = [];
const place = (territory: string) => {
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const before = structuredClone(game), action: Action = { type: 'decision', token: token.id, territory };
act('ecaz', action);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
placements.push({ before, action, after: structuredClone(game) });
act('ecaz', { type: 'decision', decline: true });
};
const shipments: { before: Game; step: EcazDukeAllyStep; after: Game }[] = [];
const ship = (actor: string, territory: string, sector: number, offer: boolean) => {
reach(state => state.phase === 5 && state.active === actor && ecazDukeAllyClean(state));
const before = structuredClone(game), step: EcazDukeAllyStep = { actor,
action: { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 } };
act(actor, step.action);
reach(state => offer
? !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer'
: ecazDukeAllyClean(state));
shipments.push({ before, step, after: structuredClone(game) });
};
place('habbanya_ridge_sietch');
ship('emperor', 'habbanya_ridge_sietch', 17, true);
const beforeSelf = structuredClone(game), selfAction: Action = { type: 'decision',
event: game.pendingAmbassador!.event, trigger: true, beneficiary: 'ecaz', choice: 'duke' };
act('ecaz', selfAction); reach(ecazDukeAllyClean);
const afterSelf = structuredClone(game);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], ['ecaz', 'ecaz', 1]);
assert.equal(game.ecazAmbassadors!.tokens.find(current => current.id === token.id)!.zone, 'supply');
reach(state => state.turn === 2 && state.phase === 1 && ecazDukeAllyClean(state));
const afterMentat = structuredClone(game);
assert.equal(game.dukeVidal!.controller, 'ecaz', 'The unused ordinary Ecaz acquisition persists across the turn boundary');
orderSpice(game, ['worm', 'hagga_basin', ...(advanced ? ['red_chasm'] : [])]);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && ecazDukeAllyClean(state));
const beforeAlliance = structuredClone(game), allianceStart = actions.length;
act('ecaz', { type: 'alliance', target: 'atreides' });
act('atreides', { type: 'alliance', target: 'ecaz' });
const alliance = { before: beforeAlliance, actions: structuredClone(actions.slice(allianceStart)), after: structuredClone(game) };
place('carthag');
ship('atreides', 'carthag', 11, false);
ship('emperor', 'carthag', 11, true);
assert.equal(game.turn, 2);
assert.equal(game.pendingAmbassador!.owner, 'ecaz');
assert.equal(game.pendingAmbassador!.entrant, 'emperor');
assert.equal(game.pendingAmbassador!.token, token.id);
assert.equal(game.dukeVidal!.controller, 'ecaz');
const event = game.pendingAmbassador!.event;
const action: Action = { type: 'decision', event, trigger: true, beneficiary: 'atreides', choice: 'duke' };
return { game, advanced, owner: 'ecaz', holder: 'ecaz', ally: 'atreides', recipient: 'atreides', entrant: 'emperor',
opponent: 'emperor', duke: DUKE_VIDAL_ID, token: token.id, event, action, initial, setup, afterSetup,
originalInventory, cohort, placements, shipments, beforeSelf, selfAction, afterSelf, afterMentat, alliance,
originalDuke: structuredClone(game.dukeVidal!), originalParent: structuredClone(game.pendingAmbassador),
originalPhysical: ecazDukeAllyPhysical(game), actions, sourceActionCount: actions.length,
staging: ['Only original unopened Spice positions and native scalar lotteries are selected. Two actual Ecaz-token placements, Emperor entries, native self-acquisition, full unused Mentat, real worm/Nexus alliance and Atreides opposing shipment create this source.'] };
}
export type EcazDukeAllyFixture = ReturnType<typeof createEcazDukeAllyFixture>;
export function grantEcazDukeAlly(fixture: EcazDukeAllyFixture, game = structuredClone(fixture.game),
actions: EcazDukeAllyStep[] = []) {
return actEcazDukeAlly(game, fixture.owner, fixture.action, actions);
}
export function finishEcazDukeAlly(fixture: EcazDukeAllyFixture, game: Game,
actions: EcazDukeAllyStep[] = []) {
return advanceEcazDukeAlly(game, state => state.turn === fixture.game.turn && !state.pendingAmbassador && ecazDukeAllyClean(state), actions);
}
export function battleEcazDukeAlly(fixture: EcazDukeAllyFixture, game: Game,
actions: EcazDukeAllyStep[] = [], options: { useDuke?: boolean } = {}) {
game = advanceEcazDukeAlly(game, state => state.phase === 6 && ecazDukeAllyClean(state), actions);
assert.ok(game.active === fixture.ally || game.active === fixture.opponent);
const chooser = game.active;
game = actEcazDukeAlly(game, chooser, { type: 'chooseBattle', territory: 'carthag',
target: chooser === fixture.ally ? fixture.opponent : fixture.ally }, actions);
game = advanceEcazDukeAlly(game, state => !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) &&
!state.battle.preparation && ecazDukeAllyClean(state), actions);
const beforePlans = structuredClone(game), opponent = [...ecazDukeAllyPlayer(game, fixture.opponent).leaders]
.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
const native = [...ecazDukeAllyPlayer(game, fixture.ally).leaders]
.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
const useDuke = options.useDuke ?? true;
assert.ok(opponent && native && (useDuke ? 6 : native.strength) > opponent.strength);
const plans: EcazDukeAllyStep[] = [
{ actor: fixture.ally, action: { type: 'battlePlan', leader: useDuke ? DUKE_VIDAL_ID : native.id, dial: 0, support: 0 } },
{ actor: fixture.opponent, action: { type: 'battlePlan', leader: opponent.id, dial: 0, support: 0 } },
];
for (const step of plans) game = actEcazDukeAlly(game, step.actor, step.action, actions);
const sealed = structuredClone(game);
game = advanceEcazDukeAlly(game, state => !state.battle && ecazDukeAllyClean(state), actions);
return { game, beforePlans, sealed, plans, actions };
}
+12
View File
@@ -33,6 +33,18 @@ function position(): Game {
} as unknown as Game;
}
void test('Cunning validates captured ordinary Ambassador tenure held by a former ally recipient', () => {
// Controlled custody input, separate from the native token/alliance source.
const g = position();
const shared = acquireDuke(g.dukeVidal!, 'g', 2, 'ecaz');
g.dukeVidal = captureDuke(shared, 'h', 'g', 3, 'carthag');
assert.equal(quoteNexusEcazDuke(g, 'e')!.blocked, null);
const receipt = createNexusEcazDukeReceipt(g, 'e');
assert.equal(receipt.before.controller, 'g');
assert.equal(receipt.before.source, 'ecaz');
assert.doesNotThrow(() => validateNexusEcazDuke(g, receipt, 0));
});
void test('Cunning sees actual capture over its own underlying Ecaz tenure and liberates it for this turn only', () => {
const g = position();
const owned = acquireDuke(g.dukeVidal!, 'e', 1, 'ecaz');
+18 -1
View File
@@ -30,6 +30,21 @@ function game(advanced = false): Game {
} as unknown as Game;
}
void test('Ecaz Cunning validates an ordinary Ambassador acquisition held by another seated recipient', () => {
// Controlled prior custody: ordinary sharing can outlast the earlier alliance.
const g = game();
g.dukeVidal = acquireDuke(g.dukeVidal!, 'atreides-seat', 2, 'ecaz');
assert.equal(quoteNexusEcazDuke(g, 'ecaz-seat')!.blocked, null);
const record = createNexusEcazDukeReceipt(g, 'ecaz-seat');
assert.equal(record.before.controller, 'atreides-seat');
assert.equal(record.before.source, 'ecaz');
g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz-seat', g.turn, 'ecazNexus');
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'ecaz-seat', g.players);
g.nexusEcazDukeHistory = [record];
g.nexusEcazDukeEvents = [record.event];
assert.doesNotThrow(() => validateNexusEcazDuke(g, record, 0));
});
void test('native physical Ecaz Cunning overrides Moritani at a quiet Battle boundary', () => {
const g = game(true);
g.dukeVidal = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
@@ -253,7 +268,9 @@ void test('damaged and re-signed impossible history cannot forge the original Du
invalid(record => { record.before.acquiredTurn = 4; });
invalid(record => { record.before.acquiredTurn = 2; });
invalid(record => { record.before.source = null; });
invalid(record => { record.before.source = 'ecaz'; });
// Ordinary Ecaz tenure may now belong to a shared recipient; Nexus tenure
// still cannot be forged for a non-Ecaz controller.
invalid(record => { record.before.source = 'ecazNexus'; });
invalid(record => {
record.before.acquiredTurn = 2;
record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, 'moritani-seat', 2);