Retire occupied Ecaz Duke tenure through captivity
This commit is contained in:
1 parent
f0ebdf9c87
commit
b5fb6aeb64
15 files changed
+767
-45
No files matched your search
@@ -3608,13 +3608,11 @@ export function GameTable({
|
||||
<p className="notice">
|
||||
{leaderName(g.decision.leader)} · strength{' '}
|
||||
{
|
||||
me.leaders.find(
|
||||
(l) =>
|
||||
l.id ===
|
||||
(g.decision?.kind === 'capturedLeader'
|
||||
? g.decision.leader
|
||||
: ''),
|
||||
)?.strength
|
||||
(g.decision.leader === 'duke-vidal'
|
||||
? g.dukeVidal?.leader
|
||||
: g.players.flatMap((seat) => seat.leaders).find(
|
||||
(leader) => leader.id === (g.decision?.kind === 'capturedLeader' ? g.decision.leader : ''),
|
||||
))?.strength
|
||||
}
|
||||
</p>
|
||||
<p className="muted">
|
||||
|
||||
@@ -26,6 +26,11 @@ export function HomeworldEcazDuke({
|
||||
yourself or your reciprocal ally. The recipient may use him in one
|
||||
battle, following the Duke’s ordinary lifecycle. Only Ecaz may revive him.
|
||||
</p>
|
||||
<p className="muted">
|
||||
Provisional custody: this benefit does not release a captured Duke.
|
||||
Captivity does not extend the original grant; a later return cannot
|
||||
restore a grant that has ended.
|
||||
</p>
|
||||
{blocked !== null && (
|
||||
<p className="notice" id="homeworld-ecaz-duke-unavailable">{blocked}</p>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,42 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Occupied Ecaz captive-tenure expiry — 27 October. Prototyped / Partial.**
|
||||
An original occupied grant now retires at public turn end or entitlement loss
|
||||
while Harkonnen keeps the actual captive. Capture, concealment and physical
|
||||
history remain; actual return rechecks the original entitlement before restoring
|
||||
any tenure. Secret execution uses its original public snapshot until public
|
||||
expiry. Both native return sites use that check. Fresh liberation remains guarded
|
||||
conservatively, and only Ecaz may revive Duke. The selected lease composition
|
||||
remains provisional. [Contract](HOMEWORLD_ECAZ_DUKE.md#captive-tenure-and-public-expiry--27-october-2026).
|
||||
|
||||
**12-file union80/80**, types/lint/build and two native runtime branches pass.
|
||||
Original public Advanced Ecaz/E/G/A/H uses Homeworlds only: actual E2 Tabr
|
||||
garrison, H1 entry, real World occupation/automatic grant, H/E battle without
|
||||
Duke, emitted unused-leader lottery and actual Keep/Execute choices. Selected
|
||||
unopened six-card/Spice positions and scalar lotteries alone are controlled;
|
||||
no played hand/force/purse/clock/occupation/grant/capture receipt is installed.
|
||||
|
||||
One139-action selected-captive prefix branches through2 actual GameTable
|
||||
callbacks,20 turn-expiry actions and55 later-use actions. The earned Keep
|
||||
producer has140 actions. Runtime renders12 GameTable/4 grant panels, checks
|
||||
relevant observer/captor views, and uses real later paid E1 entry/captive-Duke
|
||||
battle return. The capture notice now shows the shared Duke's actual strength6.
|
||||
Two early/current and conserved same-turn-departure release supplements are
|
||||
explicit helper contexts; no native departure or all-Hark-leaders-dead campaign
|
||||
is claimed. Other shipment/plan/continuation controls use native packets.
|
||||
|
||||
Feedback corrected automatic Carthag income and waited for Ecaz's actual ordered
|
||||
Revival turn. Private runtime corrections use an unrelated observer and separate
|
||||
the selected prefix from the producer's completed Keep branch. V4 carries80
|
||||
related tests from v2 and types/lint/build from v3 only after exact-source
|
||||
matching, then reruns corrected runtime; final three-document overlay follows.
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-homeworld-ecaz-captive-proof-v4/`;
|
||||
journeys/HTML: adjacent `20261027-homeworld-ecaz-captive-runtime/run-v3/`.
|
||||
Existing server HTTP200, no restart/saved-game/database operation. Wider source/
|
||||
Skill/Nexus interactions, comprehensive custody/privacy/recovery, browser/mobile/
|
||||
deployed and full-mode acceptance remain open; full AI refinement stays deferred.
|
||||
|
||||
**Caladan Sneak Attack child — 27 October. Prototyped / Partial.**
|
||||
A positive actual Moritani reserve group now has its own arrival after both
|
||||
owners of the original Caladan entry finish. BG’s free Spiritual opportunity,
|
||||
|
||||
@@ -33,6 +33,62 @@ cannot take him back. Living Ecaz Nexus Cunning likewise respects that priority;
|
||||
Ecaz's separate resurrection remains distinct. Captured, concealed or foreign
|
||||
Ghola custody is explicitly guarded rather than silently removed.
|
||||
|
||||
## Captive tenure and public expiry — 27 October 2026
|
||||
|
||||
The printed occupied face overrides Ecaz/Moritani Duke acquisition but does not
|
||||
expressly name Harkonnen captivity, concealment or foreign Ghola custody. Ecaz
|
||||
Nexus Cunning separately names exceptional custody. Targeted primary-source
|
||||
review found no specific combined liberation ruling. Retain the existing fresh-
|
||||
acquisition guard conservatively, not as a claimed publisher prohibition. The
|
||||
explicit user Ecaz-only revival override remains binding.
|
||||
|
||||
The existing provisional lease still ends at public turn end, occupation/
|
||||
qualification loss or loss of the selected reciprocal ally. Captivity does not
|
||||
extend it. Retire the original tenure coherently without moving the actual
|
||||
captured disc, changing the captor, erasing concealment/deaths/use or restoring
|
||||
an earlier controller. A secret execution keeps its public leader snapshot until
|
||||
an ordinary public expiry; private death/use is not an expiry signal. End-turn
|
||||
retirement follows the existing turn-end producer even while ordinary settled
|
||||
reconciliation is waiting on other parents.
|
||||
|
||||
An actual captive return independently rechecks the original receipt/current
|
||||
qualification before restoring tenure. This applies to both native return sites:
|
||||
all native Harkonnen leaders dead, or the captive's battle use. Still-current
|
||||
eligible early return retains its original grant and creates no second receipt;
|
||||
ended tenure returns set aside. Other Duke sources keep their existing rules.
|
||||
Retirement is a selected provisional tenure composition, not new permission to
|
||||
liberate or revive Duke. Public expiry messages stay generic across ordinary,
|
||||
held and secret-return cases.
|
||||
|
||||
Twelve related files pass80/80, with types/lint/build. Seven controlled helper
|
||||
cases and seven new engine cases distinguish pure custody witnesses, actual
|
||||
native chronology and helper-only current/departed release contexts. The original
|
||||
five-seat public Advanced Ecaz/E/G/A/H source earns real occupation, grant,
|
||||
second battle, emitted unused-Duke capture and Keep. E's original paid2 garrison
|
||||
uses empty Tabr, H really sends1 there, and Duke stays unused in that battle.
|
||||
No played hand/force/purse/clock/occupation/grant/capture receipt is installed;
|
||||
only unopened six-card/Spice positions and scalar lotteries are selected.
|
||||
Existing four-seat defaults remain; stamped Charity/Revival follows current
|
||||
native order. The old revival test now waits for Ecaz's own opportunity.
|
||||
|
||||
The runtime takes a139-action selected prefix into actual Keep/Execute callbacks;
|
||||
the full Keep producer is140 actions. Twelve table/four grant renders observe
|
||||
both actual public expiries, relevant observer/captor views and the native later
|
||||
paid E1 entry/captive-Duke battle return (55 actions). Two helper supplements
|
||||
are labelled; no played same-turn departure or five-native-Hark-deaths release
|
||||
campaign is claimed. Captured choices now display Duke's projected strength6
|
||||
rather than looking only in the captor's native roster. No stateful shipment/
|
||||
plan controls, browser/hydration/mobile or deployed proof is claimed.
|
||||
|
||||
V4 matches the frozen source exactly before carrying v2's80-case union and
|
||||
v3's types/lint/build, then reruns the corrected observer/prefix runtime. Final
|
||||
three-document evidence overlay follows. Earlier failed records stay preserved.
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-homeworld-ecaz-captive-proof-v4/`;
|
||||
journeys/HTML: adjacent `20261027-homeworld-ecaz-captive-runtime/run-v3/`.
|
||||
No restart/saved-game/database operation. Wider custody/Skill/Nexus compositions,
|
||||
comprehensive final assurance and complete-mode/browser/deployed acceptance remain
|
||||
open. This rule delivery is not a capture-resumption/save/privacy assurance sweep.
|
||||
|
||||
## Connected controls and minimal policies
|
||||
|
||||
The owner-only `homeworldEcazDuke` view contains original event, projected
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Occupied Ecaz captive-tenure expiry
|
||||
|
||||
Public deadline/entitlement loss retires only the original occupied grant while
|
||||
actual captivity, concealment and physical history remain. Both captive-return
|
||||
sites recheck entitlement. Secret execution retains its public snapshot until
|
||||
ordinary public expiry. Fresh liberation stays guarded; Ecaz-only revival stays.
|
||||
The selected lease composition remains provisional. Captured Duke strength6 is
|
||||
now displayed from the shared projected disc.
|
||||
|
||||
Twelve-file80/80, types/lint/build and native Keep/Execute expiry branches pass.
|
||||
One139-action prefix (140 for the complete earned Keep source),2 actual capture
|
||||
callbacks,20 expiry/55 later-use actions and12 table/4 grant renders distinguish
|
||||
native chronology from2 helper-only release supplements. V4 carries exact-source
|
||||
v2 tests/v3 quick+build after private observer/prefix corrections, then repeats
|
||||
runtime. Failed records stay preserved; final three-document overlay follows.
|
||||
[Current checkpoint](CURRENT_STATUS.md#current-checkpoint-and-work) and
|
||||
[contract/proof/limits](HOMEWORLD_ECAZ_DUKE.md#captive-tenure-and-public-expiry--27-october-2026).
|
||||
No restart/saved-game/database operation, broad capture/privacy/recovery assurance
|
||||
or browser/deployed/full-mode certification. Full AI refinement remains deferred.
|
||||
|
||||
## 27 October 2026 — Caladan Sneak Attack child arrival
|
||||
|
||||
A positive actual Sneak group now receives a separate opaque source, after the
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Rule decision index
|
||||
|
||||
## Occupied Ecaz grant expiry during captivity — 27 October 2026
|
||||
|
||||
Extend the selected provisional one-battle/turn/qualification tenure through
|
||||
Harkonnen custody without freeing the captive. Public expiry retires only that
|
||||
original grant; actual return rechecks its entitlement and cannot restore an
|
||||
ended lease. Preserve actual captor, concealment and physical history. A secret
|
||||
execution is not a public expiry signal; use its original public leader snapshot
|
||||
until ordinary public deadline/loss. Both native return sites retain this check.
|
||||
The occupied face does not expressly name exceptional custody; unlike Ecaz Nexus
|
||||
Cunning, it supplies no retrieved liberation ruling. Retain fresh captured/
|
||||
concealed/Ghola acquisition guarded conservatively, and keep the user Ecaz-only
|
||||
revival override. [Contract](HOMEWORLD_ECAZ_DUKE.md#captive-tenure-and-public-expiry--27-october-2026).
|
||||
|
||||
## Caladan-triggered Sneak Attack child — 27 October 2026
|
||||
|
||||
A positive actual Sneak group receives its own off-planet arrival source, after
|
||||
|
||||
+36
-2
@@ -260,6 +260,36 @@ export function consumeDuke(state: DukeState): DukeState {
|
||||
return released;
|
||||
}
|
||||
|
||||
/** End only the original occupied-Ecaz lease. Harkonnen still holds the same
|
||||
* disc, and a concealed return keeps its original public leader snapshot. */
|
||||
export function retireHomeworldDukeTenure(state: DukeState, turn: number): DukeState {
|
||||
validTurn(turn);
|
||||
const original = state.source === 'homeworldEcaz' ? state :
|
||||
state.captureVisibility?.source === 'homeworldEcaz' ? state.captureVisibility : null;
|
||||
if (!original) return cloneDuke(state);
|
||||
validDisc(state);
|
||||
validUnderlay(state, turn);
|
||||
validUnderlay(original, turn);
|
||||
if (state.leader.capturedBy) validCaptive(state, turn);
|
||||
else if ((state.leader.concealed || state.captureVisibility) && !returnedDukeCapture(state))
|
||||
throw new Error('Duke Vidal lost his original concealed return.');
|
||||
if (state.leader.gholaBy)
|
||||
throw new Error('Occupied Ecaz cannot retire foreign Ghola custody.');
|
||||
const retired = cloneDuke(state);
|
||||
if (retired.source === 'homeworldEcaz') {
|
||||
retired.controller = null;
|
||||
retired.acquiredTurn = null;
|
||||
retired.source = null;
|
||||
}
|
||||
if (retired.captureVisibility?.source === 'homeworldEcaz') {
|
||||
retired.captureVisibility = { controller: null, acquiredTurn: null, source: null };
|
||||
}
|
||||
if (retired.leader.capturedBy) validCaptive(retired, turn);
|
||||
else if ((retired.leader.concealed || retired.captureVisibility) && !returnedDukeCapture(retired))
|
||||
throw new Error('Duke Vidal lost his original concealed return.');
|
||||
return retired;
|
||||
}
|
||||
|
||||
/**
|
||||
* End-turn custody only. Ecaz's ordinary Ambassador acquisition lasts until
|
||||
* battle use; Ecaz Nexus Cunning and the Basic Ambassador ally loan last this
|
||||
@@ -269,8 +299,12 @@ export function consumeDuke(state: DukeState): DukeState {
|
||||
*/
|
||||
export function expireDuke(state: DukeState, turn: number): DukeState {
|
||||
validTurn(turn);
|
||||
const homeworld = state.source === 'homeworldEcaz' ? state :
|
||||
state.captureVisibility?.source === 'homeworldEcaz' ? state.captureVisibility : null;
|
||||
if (homeworld && homeworld.acquiredTurn !== null && homeworld.acquiredTurn <= turn)
|
||||
return retireHomeworldDukeTenure(state, turn);
|
||||
if (
|
||||
(state.source === 'moritani' || state.source === 'ecazNexus' || state.source === 'ally' || state.source === 'homeworldEcaz') &&
|
||||
(state.source === 'moritani' || state.source === 'ecazNexus' || state.source === 'ally') &&
|
||||
state.acquiredTurn !== null &&
|
||||
state.acquiredTurn <= turn &&
|
||||
!state.leader.dead &&
|
||||
@@ -294,7 +328,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
|
||||
'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.',
|
||||
'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.',
|
||||
'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. Captivity does not extend this grant; the captor keeps normal capture rights, and a later return cannot restore an ended grant.',
|
||||
'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.',
|
||||
]),
|
||||
|
||||
+8
-7
@@ -182,7 +182,8 @@ import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homew
|
||||
import { createOccupiedPercentageState, quoteOccupiedPercentageSource, allocateOccupiedPercentageReceipt, validateOccupiedPercentageState, type OccupiedPercentageState, type OccupiedPercentageSource } from './homeworld-occupied-percentage';
|
||||
import { quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, requireOccupiedBiddingBonusRecipient } from './homeworld-occupied-bidding';
|
||||
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
|
||||
import { quoteHomeworldEcazDuke, createHomeworldEcazDukeReceipt, quoteHomeworldEcazDukeLifecycle, type HomeworldEcazDukeReceipt } from './homeworld-ecaz-duke';
|
||||
import { quoteHomeworldEcazDuke, createHomeworldEcazDukeReceipt, quoteHomeworldEcazDukeLifecycle,
|
||||
releaseCapturedHomeworldDuke, type HomeworldEcazDukeReceipt } from './homeworld-ecaz-duke';
|
||||
import { createOccupiedTupileState, syncOccupiedTupileLease, quoteOccupiedTupileAuthority, quoteOccupiedTupileCleanup, completeOccupiedTupileCleanup, validateOccupiedTupileState, type OccupiedTupileState } from './homeworld-occupied-tupile';
|
||||
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryProgress } from './ecaz-homeworld-victory';
|
||||
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
|
||||
@@ -565,7 +566,7 @@ import {
|
||||
expireDuke,
|
||||
dukeController,
|
||||
captureDuke,
|
||||
releaseCapturedDuke,
|
||||
retireHomeworldDukeTenure,
|
||||
executeCapturedDuke,
|
||||
returnedDukeCapture,
|
||||
type DukeReleasedCaptureWitness,
|
||||
@@ -27411,9 +27412,9 @@ function reconcileHomeworldEcazDuke(g: Game) {
|
||||
if (g.status !== 'playing' || !g.homeworldOccupationPreview || !byFaction(g, 'ecaz') || homeworldEcazDukeBoundaryBlocked(g)) return;
|
||||
const lifecycle = homeworldRule(() => quoteHomeworldEcazDukeLifecycle(g));
|
||||
if (lifecycle.blocked) return;
|
||||
if (lifecycle.release && g.dukeVidal?.source === 'homeworldEcaz') {
|
||||
g.dukeVidal = consumeDuke(g.dukeVidal);
|
||||
log(g, `The occupied Ecaz Duke grant ended: ${lifecycle.reason ?? 'its original entitlement ended'}. Duke Vidal is set aside without restoring an earlier controller.`);
|
||||
if (lifecycle.release && g.dukeVidal) {
|
||||
g.dukeVidal = retireHomeworldDukeTenure(g.dukeVidal, g.turn);
|
||||
log(g, `The occupied Ecaz Duke grant ended: ${lifecycle.reason ?? 'its original entitlement ended'}. Its original tenure is retired.`);
|
||||
}
|
||||
const quote = homeworldRule(() => quoteHomeworldEcazDuke(g));
|
||||
const duke = g.dukeVidal;
|
||||
@@ -28381,7 +28382,7 @@ function returnCaptives(g: Game, captor: Player) {
|
||||
returned++;
|
||||
}
|
||||
if (g.dukeVidal?.leader.capturedBy === captor.id) {
|
||||
g.dukeVidal = releaseCapturedDuke(g.dukeVidal, captor.id, g.turn);
|
||||
g.dukeVidal = homeworldRule(() => releaseCapturedHomeworldDuke(g, captor.id));
|
||||
returned++;
|
||||
}
|
||||
if (returned)
|
||||
@@ -31431,7 +31432,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
if (l && !l.dead) l.usedAt = b.territory;
|
||||
if (l?.capturedBy === p.id) {
|
||||
if (l.id === DUKE_VIDAL_ID && g.dukeVidal)
|
||||
g.dukeVidal = releaseCapturedDuke(g.dukeVidal, p.id, g.turn);
|
||||
g.dukeVidal = homeworldRule(() => releaseCapturedHomeworldDuke(g, p.id));
|
||||
else {
|
||||
delete l.capturedBy;
|
||||
delete l.concealed;
|
||||
|
||||
+36
-15
@@ -1,5 +1,5 @@
|
||||
import type { Game } from './engine';
|
||||
import { acquireDuke, type DukeState } from './duke-vidal';
|
||||
import { acquireDuke, releaseCapturedDuke, retireHomeworldDukeTenure, type DukeState } from './duke-vidal';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import {
|
||||
quoteStableHomeworldOccupation, type StableHomeworldOccupationContext,
|
||||
@@ -123,31 +123,52 @@ export function createHomeworldEcazDukeReceipt(g: HomeworldEcazDukeContext, acto
|
||||
return { turn: g.turn, qualification: quote.qualification, occupier: actor, recipient };
|
||||
}
|
||||
|
||||
/** Call at an engine-owned settled boundary, after original battle/arrival
|
||||
* parents. Release means set the same disc aside, never restore native tenure.
|
||||
* Foreign custody is explicitly pending rather than erased by lease cleanup. */
|
||||
export function quoteHomeworldEcazDukeLifecycle(g: HomeworldEcazDukeContext): HomeworldEcazDukeLifecycleQuote {
|
||||
/** The original public tenure can outlive actual secret execution. Only its
|
||||
* public snapshot and qualification decide when that tenure ends. */
|
||||
function homeworldDukeLifecycle(g: HomeworldEcazDukeContext, custodyBlocked: boolean): HomeworldEcazDukeLifecycleQuote {
|
||||
const none: HomeworldEcazDukeLifecycleQuote = { release: false, owner: null, reason: null, blocked: null };
|
||||
const duke = g.dukeVidal;
|
||||
if (duke?.source !== HOMEWORLD_ECAZ_DUKE_SOURCE) return none;
|
||||
if (!duke) return none;
|
||||
const tenure = duke.source === HOMEWORLD_ECAZ_DUKE_SOURCE ? duke :
|
||||
duke.captureVisibility?.source === HOMEWORLD_ECAZ_DUKE_SOURCE ? duke.captureVisibility : null;
|
||||
if (!tenure) return none;
|
||||
validateHomeworldEcazDukeGrants(g);
|
||||
const receipt = g.homeworldEcazDukeGrants?.findLast(receipt =>
|
||||
receipt.turn === duke.acquiredTurn && receipt.recipient === duke.controller);
|
||||
requireGrant(receipt && duke.controller, 'The active occupied Ecaz Duke lost its original grant.');
|
||||
const own = { ...none, owner: duke.controller };
|
||||
if (duke.leader.capturedBy || duke.leader.gholaBy || duke.leader.concealed || duke.captureVisibility)
|
||||
return { ...own, blocked: pendingCustody };
|
||||
receipt.turn === tenure.acquiredTurn && receipt.recipient === tenure.controller);
|
||||
requireGrant(receipt && tenure.controller, 'The active occupied Ecaz Duke lost its original grant.');
|
||||
const own = { ...none, owner: tenure.controller };
|
||||
const release = (reason: NonNullable<HomeworldEcazDukeLifecycleQuote['reason']>): HomeworldEcazDukeLifecycleQuote =>
|
||||
({ ...own, release: true, reason });
|
||||
if (duke.leader.dead) return release('dead');
|
||||
if (duke.leader.usedAt) return release('used');
|
||||
if (duke.acquiredTurn !== g.turn) return release('turnEnded');
|
||||
const visibleLeader = duke.leader.concealed ?? duke.leader;
|
||||
if (visibleLeader.dead) return release('dead');
|
||||
if (visibleLeader.usedAt) return release('used');
|
||||
if (tenure.acquiredTurn !== g.turn) return release('turnEnded');
|
||||
const occupation = quoteStableHomeworldOccupation(g, 'ecaz');
|
||||
if (occupation.blocked) return { ...own, blocked: occupation.blocked };
|
||||
const source = occupation.entitlement;
|
||||
if (!source) return release('occupationEnded');
|
||||
if (source.qualification !== receipt.qualification || source.occupier !== receipt.occupier)
|
||||
return release('occupationChanged');
|
||||
if (duke.controller !== source.occupier && duke.controller !== source.ally) return release('recipientChanged');
|
||||
if (tenure.controller !== source.occupier && tenure.controller !== source.ally) return release('recipientChanged');
|
||||
if (custodyBlocked && (duke.leader.capturedBy || duke.leader.gholaBy || duke.leader.concealed || duke.captureVisibility))
|
||||
return { ...own, blocked: pendingCustody };
|
||||
return own;
|
||||
}
|
||||
|
||||
/** Call after original battle/arrival parents. Release retires only the lease;
|
||||
* it never frees a captured disc or changes a concealed public snapshot. */
|
||||
export function quoteHomeworldEcazDukeLifecycle(g: HomeworldEcazDukeContext): HomeworldEcazDukeLifecycleQuote {
|
||||
return homeworldDukeLifecycle(g, true);
|
||||
}
|
||||
|
||||
/** Recheck the original lease at the actual captive return, even when the
|
||||
* ordinary settled-boundary reconciliation is still waiting on its parent. */
|
||||
export function releaseCapturedHomeworldDuke(g: HomeworldEcazDukeContext,
|
||||
captor: string, turn = g.turn): DukeState {
|
||||
requireGrant(g.dukeVidal, 'Choose the original captured Duke Vidal.');
|
||||
const current = turn === g.turn ? g : { ...g, turn };
|
||||
const lifecycle = homeworldDukeLifecycle(current, false);
|
||||
requireGrant(!lifecycle.blocked, lifecycle.blocked ?? 'Occupied Ecaz Duke lost its current qualification.');
|
||||
const state = lifecycle.release ? retireHomeworldDukeTenure(g.dukeVidal, turn) : g.dukeVidal;
|
||||
return releaseCapturedDuke(state, captor, turn);
|
||||
}
|
||||
+3
-3
@@ -5714,15 +5714,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'When only the occupier is eligible, the native grant settles automatically. With a reciprocal ally, the occupier chooses a recipient in persistent controls at an otherwise quiet boundary before either Battle Plan seals. Finish committed interactions first.',
|
||||
'The provisional ordinary grant permits one battle and expires unused at turn end. A continuing occupation gives a new recipient choice next turn. A choice is recorded once per turn and original qualification; current used-at history cannot be evaded by leaving and returning.',
|
||||
'Loss of occupation, a changed qualifier or loss of the selected ally ends only this grant and never restores an old controller. Occupation overrides ordinary native Ecaz/Moritani Duke acquisition; living Nexus Cunning also respects that priority.',
|
||||
'Only Ecaz may revive Duke Vidal under the user-selected rule, even while Ecaz is occupied. This grant cannot revive him. Captured, concealed or foreign-Ghola transfer remains unfinished. The same shared disc, physical death history and native battle use remain.',
|
||||
'Only Ecaz may revive Duke Vidal under the user-selected rule, even while Ecaz is occupied. This grant cannot revive him. Fresh captured, concealed or foreign-Ghola acquisition stays guarded conservatively. Captivity does not extend an original occupied grant: the captor keeps normal capture rights, and a later return cannot restore tenure ended by public deadline or entitlement loss. The same shared disc, physical death history and native battle use remain.',
|
||||
],
|
||||
related: ['homeworlds', 'duke-vidal', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Stable source qualification, original recipient receipt, ordinary shared-disc tenure, native priority, automatic sole choice and settled loss/turn/use release are connected. Captive/Ghola transfer remains bounded.', evidence: ['game/homeworld-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Stable source qualification, original recipient receipt, ordinary shared-disc tenure, native priority, automatic sole choice and settled loss/turn/use release are connected. Public captive lease expiry and actual-return entitlement checks preserve the existing capture; fresh liberation remains guarded.', evidence: ['game/homeworld-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only projected labels, persistent provisional guidance and event-bound busy/blocked recipient controls connect through GameTable.', evidence: ['components/homeworld-ecaz-duke.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four existing profiles use only the shared legal recipient descriptor; no strategic refinement or calibration.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed grant, user revival override and selected ordinary tenure are distinct from complete Homeworld/Duke acceptance.', evidence: ['docs/HOMEWORLD_ECAZ_DUKE.md', 'docs/RULE_DECISIONS.md', 'docs/DUKE_VIDAL_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Controlled pure/controls cases and a separate genuine native occupation source provide scoped evidence. Current frozen counts/runtime limitations are in the source contract; complete modes and final assurance remain open.', evidence: ['tests/homeworld-ecaz-duke-quote.test.ts', 'tests/homeworld-ecaz-duke-controls.test.tsx', 'tests/homeworld-ecaz-duke-engine.test.ts', 'tests/fixture-homeworld-ecaz-duke.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Controlled pure/controls cases and a separate genuine native occupation source provide scoped evidence. Current frozen counts/runtime limitations are in the source contract; complete modes and final assurance remain open.', evidence: ['tests/homeworld-ecaz-duke-captive.test.ts', 'tests/homeworld-ecaz-duke-captive-engine.test.ts', 'tests/fixture-homeworld-ecaz-duke-captive.ts', 'tests/homeworld-ecaz-duke-quote.test.ts', 'tests/homeworld-ecaz-duke-controls.test.tsx', 'tests/homeworld-ecaz-duke-engine.test.ts', 'tests/fixture-homeworld-ecaz-duke.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, captureCandidates, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { quoteHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import {
|
||||
createHomeworldEcazDukeFixture, actHomeworldEcazDuke,
|
||||
homeworldEcazDukeClean, homeworldEcazDukePlayer, homeworldEcazDukeInventory,
|
||||
nextHomeworldEcazDukeStep, type HomeworldEcazDukeStep,
|
||||
} from './fixture-homeworld-ecaz-duke';
|
||||
|
||||
export const ecazDukeCaptivePlayer = homeworldEcazDukePlayer;
|
||||
export const ecazDukeCaptiveInventory = homeworldEcazDukeInventory;
|
||||
export const ecazDukeCaptiveClean = homeworldEcazDukeClean;
|
||||
export type EcazDukeCaptiveStep = HomeworldEcazDukeStep;
|
||||
|
||||
/** Only selects the original random permutation over the engine's actual
|
||||
* capture pool. Native UUID/non-scalar entropy is untouched. */
|
||||
function selectDukeCapture<T>(game: Game, run: () => T): T {
|
||||
const capture = game.pendingCapture; assert.ok(capture);
|
||||
const pool = captureCandidates(game,capture.loser,capture.territory,capture.duke);
|
||||
const duke = pool.find(l => l.id === DUKE_VIDAL_ID); assert.ok(duke);
|
||||
const desired = [duke,...pool.filter(l => l.id !== DUKE_VIDAL_ID)];
|
||||
const current = [...pool], rolls: number[] = [];
|
||||
for (let i = current.length-1; i > 0; i--) {
|
||||
const j = current.findIndex(l => l.id === desired[i].id);
|
||||
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.map(l => l.id),desired.map(l => l.id));
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto,'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let index = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView|null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1 && index < rolls.length)
|
||||
array[0] = rolls[index++];
|
||||
else Reflect.apply(native,crypto,[array]);
|
||||
return array;
|
||||
};
|
||||
try { const result=run(); assert.ok(index===0 || index===rolls.length); return result; }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto,'getRandomValues',descriptor);
|
||||
else Reflect.deleteProperty(crypto,'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
export function actEcazDukeCaptive(game: Game, actor: string, action: Action,
|
||||
actions: EcazDukeCaptiveStep[] = []): Game {
|
||||
if (game.pendingCapture && (game.response?.kind === 'capture' ||
|
||||
game.decision?.kind === 'captureOffer' && action.type === 'decision' && action.accept === true)) {
|
||||
const result = selectDukeCapture(game,() => applyAction(game,actor,action));
|
||||
actions.push({actor,action:structuredClone(action)}); return result;
|
||||
}
|
||||
return actHomeworldEcazDuke(game,actor,action,actions);
|
||||
}
|
||||
|
||||
export function advanceEcazDukeCaptive(game: Game, until: (game: Game) => boolean,
|
||||
actions: EcazDukeCaptiveStep[] = []): Game {
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status,'finished');
|
||||
const next = game.decision?.kind === 'captureOffer'
|
||||
? {actor:game.decision.player,action:{type:'decision',accept:false} as Action}
|
||||
: nextHomeworldEcazDukeStep(game);
|
||||
assert.ok(next,`Choose the current native Duke/battle action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actEcazDukeCaptive(game,next.actor,next.action,actions);
|
||||
}
|
||||
assert.fail('The bounded occupied-Duke captive chronology did not reach its boundary.');
|
||||
}
|
||||
|
||||
function planReady(game: Game) {
|
||||
return homeworldEcazDukeClean(game) && !!game.battle && !game.battle.revealed &&
|
||||
!game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed);
|
||||
}
|
||||
function nativeLeader(game: Game, actor: string, strongest: boolean) {
|
||||
const leaders = homeworldEcazDukePlayer(game,actor).leaders.filter(l =>
|
||||
!l.dead && !l.capturedBy && !l.gholaBy && !l.usedAt);
|
||||
leaders.sort((a,b) => strongest ? b.strength-a.strength : a.strength-b.strength);
|
||||
assert.ok(leaders[0]); return leaders[0].id;
|
||||
}
|
||||
|
||||
/** Entire public original E/G/A/Ecaz/H source: real paid Tabr garrison and
|
||||
* Harkonnen entry, actual Ecaz World victory/qualification/automatic lease,
|
||||
* actual Harkonnen victory over E WITHOUT using Duke, then original native
|
||||
* unused-leader capture lottery and Keep. No played custody is installed. */
|
||||
export function createHomeworldEcazDukeCaptiveFixture() {
|
||||
const source = createHomeworldEcazDukeFixture({advanced:true,harkonnen:true});
|
||||
let game = structuredClone(source.game);
|
||||
const actions: EcazDukeCaptiveStep[] = [...source.sourceActions], start = actions.length;
|
||||
const owner = source.occupier, captor = source.opponent;
|
||||
assert.equal(captor,'harkonnen'); assert.equal(game.battle?.territory,'sietch_tabr');
|
||||
assert.equal(game.dukeVidal?.source,'homeworldEcaz');
|
||||
assert.equal(game.dukeVidal?.controller,owner); assert.equal(game.dukeVidal?.leader.usedAt,undefined);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const plans = {
|
||||
[owner]:{leader:nativeLeader(game,owner,false),dial:0,support:0},
|
||||
[captor]:{leader:nativeLeader(game,captor,true),dial:0,support:0},
|
||||
};
|
||||
for (const actor of [game.battle!.attacker,game.battle!.defender])
|
||||
game = actEcazDukeCaptive(game,actor,{type:'battlePlan',...plans[actor]},actions);
|
||||
game = advanceEcazDukeCaptive(game,g => g.decision?.kind === 'captureOffer',actions);
|
||||
assert.equal(game.lastBattleContext?.winner,captor);
|
||||
const captureOffer = structuredClone(game), pending = game.pendingCapture!;
|
||||
const candidates = captureCandidates(game,pending.loser,pending.territory,pending.duke).map(l => l.id);
|
||||
assert.ok(candidates.includes(DUKE_VIDAL_ID));
|
||||
game = actEcazDukeCaptive(game,captor,{type:'decision',accept:true},actions);
|
||||
game = advanceEcazDukeCaptive(game,g => g.decision?.kind === 'capturedLeader',actions);
|
||||
assert.equal(game.decision?.kind === 'capturedLeader' && game.decision.leader,DUKE_VIDAL_ID);
|
||||
const selected = structuredClone(game);
|
||||
game = actEcazDukeCaptive(game,captor,{type:'decision',mode:'keep'},actions);
|
||||
game = advanceEcazDukeCaptive(game,g => !g.battle && homeworldEcazDukeClean(g),actions);
|
||||
assert.equal(game.dukeVidal?.leader.capturedBy,captor);
|
||||
assert.equal(dukeController(game.dukeVidal!),captor);
|
||||
assert.equal(game.dukeVidal?.controller,owner);
|
||||
assert.equal(game.dukeVidal?.leader.usedAt,undefined);
|
||||
assert.deepEqual(homeworldEcazDukeInventory(game),source.originalInventory);
|
||||
homeworldGameIntegrity(game);
|
||||
return {game,source,beforeBattle,captureOffer,selected,candidates,plans,owner,captor,
|
||||
dukeId:DUKE_VIDAL_ID,world:source.world,territory:source.secondTerritory,sector:source.secondSector,
|
||||
actions,sourceActions:actions,sourceCount:actions.length,captureActions:actions.slice(start),
|
||||
disclosure:'Original public Advanced Ecaz/E/G/A/H with Homeworlds only; selected unopened original six-card/Spice orders and native scalar lotteries. Real paid shipments, World occupation/grant, second battle, emitted unused-Duke capture lottery and Keep; no installed occupation, lease or capture receipt.'};
|
||||
}
|
||||
export type HomeworldEcazDukeCaptiveFixture = ReturnType<typeof createHomeworldEcazDukeCaptiveFixture>;
|
||||
|
||||
export function expireEcazDukeCaptive(f: HomeworldEcazDukeCaptiveFixture, source = f.game) {
|
||||
const actions: EcazDukeCaptiveStep[] = [], before = structuredClone(source);
|
||||
const game = advanceEcazDukeCaptive(structuredClone(source),g => g.turn > before.turn &&
|
||||
g.phase === 0 && homeworldEcazDukeClean(g),actions);
|
||||
assert.equal(game.dukeVidal?.leader.capturedBy,f.captor);
|
||||
return {game,before,actions};
|
||||
}
|
||||
|
||||
/** Actual later paid E entry into surviving Harkonnen Tabr1, actual native
|
||||
* captive-Duke plan, reveal/calls and battle release. No army is restaged. */
|
||||
export function useExpiredEcazDukeCaptive(f: HomeworldEcazDukeCaptiveFixture, source: Game) {
|
||||
let game = structuredClone(source); const actions: EcazDukeCaptiveStep[] = [];
|
||||
game = advanceEcazDukeCaptive(game,g => g.phase === 5 && g.active === f.owner &&
|
||||
homeworldEcazDukeClean(g),actions);
|
||||
const homeworldSources = nativeShipmentSources(viewGame(game,f.owner),1,0); assert.ok(homeworldSources);
|
||||
const beforeShip = structuredClone(game);
|
||||
game = actEcazDukeCaptive(game,f.owner,{type:'ship',territory:f.territory,sector:f.sector,
|
||||
amount:1,elite:0,allyPayment:0,homeworldSources},actions);
|
||||
game = advanceEcazDukeCaptive(game,g => g.phase === 6 && !!g.active &&
|
||||
!g.battle && homeworldEcazDukeClean(g),actions);
|
||||
const actor = game.active!;
|
||||
assert.ok(viewGame(game,actor).battleChoices.some(choice => choice.territory === f.territory));
|
||||
game = actEcazDukeCaptive(game,actor,{type:'chooseBattle',territory:f.territory,
|
||||
target:actor === f.owner ? f.captor : f.owner},actions);
|
||||
game = advanceEcazDukeCaptive(game,planReady,actions);
|
||||
const beforeBattle = structuredClone(game), enemy = nativeLeader(game,f.owner,false);
|
||||
for (const id of [game.battle!.attacker,game.battle!.defender])
|
||||
game = actEcazDukeCaptive(game,id,{type:'battlePlan',leader:id === f.captor ? DUKE_VIDAL_ID : enemy,
|
||||
dial:0,support:0},actions);
|
||||
game = advanceEcazDukeCaptive(game,g => !g.battle && homeworldEcazDukeClean(g),actions);
|
||||
assert.equal(game.dukeVidal?.leader.capturedBy,undefined);
|
||||
homeworldGameIntegrity(game);
|
||||
return {game,beforeShip,beforeBattle,actions};
|
||||
}
|
||||
|
||||
/** LABELLED CONTROLLED HELPER SUPPLEMENT: move the three actually earned E
|
||||
* visitors back into their actual native World, conserving pools. This does
|
||||
* not claim a played departure or synthesize an occupation observation. It is
|
||||
* used only to ask the release helper about an already-lost current entitlement. */
|
||||
export function controlledEcazDukeCaptiveDeparture(source: Game) {
|
||||
const game = structuredClone(source), owner = 'emperor', world = 'homeworld:ecaz';
|
||||
const group = game.homeworlds!.custody!.visitors[world][owner];
|
||||
assert.ok(group && group.normal > 0 && group.elite === 0);
|
||||
const quote = quoteHomeworldCustody(homeworldContext(game),game.homeworlds!.custody!,[
|
||||
{homeworld:world,player:owner,withdraw:{...group},deposit:{normal:0,elite:0}},
|
||||
{homeworld:'homeworld:emperor',player:owner,withdraw:{normal:0,elite:0},deposit:{...group}},
|
||||
]);
|
||||
game.homeworlds!.custody = quote.state;
|
||||
for (const seat of quote.players) homeworldEcazDukePlayer(game,seat.id).reserves = seat.reserves;
|
||||
homeworldGameIntegrity(game);
|
||||
return game;
|
||||
}
|
||||
@@ -66,6 +66,8 @@ export function nextHomeworldEcazDukeStep(game: Game): HomeworldEcazDukeStep | n
|
||||
}
|
||||
}
|
||||
if (!decision && game.status === 'playing') {
|
||||
if (game.phase === 2 && game.charityOrder || game.phase === 4 && game.revivalOrder)
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
if (game.phase === 0 && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
|
||||
@@ -104,11 +106,13 @@ function settleNativeBattle(game: Game, plans: Record<string, Plan>, actions: Ho
|
||||
/** Genuine two-turn source: paid Dune garrison, deferred real funding, native
|
||||
* worm alliance, typed invasion and a real World victory. All physical sources
|
||||
* and later Duke battle are earned; only unopened card/Spice orders are chosen. */
|
||||
export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; allied?: boolean } = {}) {
|
||||
export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; allied?: boolean; harkonnen?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false, allied = options.allied ?? false;
|
||||
const ecaz = 'ecaz', occupier = 'emperor', guild = 'guild', opponent = 'atreides', ally = allied ? guild : null;
|
||||
assert.ok(!options.harkonnen || !allied, 'The native Harkonnen capture source uses the unallied grant.');
|
||||
const ecaz = 'ecaz', occupier = 'emperor', guild = 'guild', opponent = options.harkonnen ? 'harkonnen' : 'atreides', ally = allied ? guild : null;
|
||||
let game = createGame('HOMEWORLDECAZDUKE', newPlayer(ecaz, 'Ecaz', 'ecaz'), advanced, ['ecaz']);
|
||||
for (const faction of [occupier, guild, opponent] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
for (const faction of [occupier, guild, 'atreides', ...(options.harkonnen ? ['harkonnen'] as const : [])] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: HomeworldEcazDukeStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actHomeworldEcazDuke(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldEcazDuke(game, until, actions); };
|
||||
@@ -131,7 +135,10 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
|
||||
const projectile = game.deck.find(card => card.kind === 'projectile' && !card.effect),
|
||||
snooper = game.deck.find(card => card.kind === 'snooper');
|
||||
assert.equal(poisons.length, 2); assert.ok(projectile && snooper);
|
||||
const selected = [snooper, poisons[0], poisons[1], projectile];
|
||||
const harkonnenCards = options.harkonnen ? [game.deck.find(card => card.kind === 'worthless'),
|
||||
game.deck.find(card => card.kind === 'shield')] : [];
|
||||
assert.ok(harkonnenCards.every(Boolean));
|
||||
const selected = [snooper, poisons[0], poisons[1], projectile, ...harkonnenCards.filter(card => !!card)];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
@@ -162,13 +169,14 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
|
||||
assert.equal(homeworldEcazDukePlayer(funding.after, occupier).spice, homeworldEcazDukePlayer(funding.before, occupier).spice);
|
||||
assert.equal(homeworldEcazDukePlayer(funding.after, occupier).bribes, homeworldEcazDukePlayer(funding.before, occupier).bribes + 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === occupier && homeworldEcazDukeClean(state));
|
||||
const garrisonTerritory = options.harkonnen ? 'sietch_tabr' : 'carthag', garrisonSector = options.harkonnen ? 14 : 11;
|
||||
const duneShipment = transition(() => {
|
||||
const sources = nativeShipmentSources(viewGame(game, occupier), 2, 0); assert.ok(sources);
|
||||
act(occupier, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0,
|
||||
act(occupier, { type: 'ship', territory: garrisonTerritory, sector: garrisonSector, amount: 2, elite: 0, allyPayment: 0,
|
||||
homeworldSources: sources });
|
||||
reach(state => homeworldEcazDukeClean(state) && !state.pendingShipment);
|
||||
});
|
||||
assert.equal(homeworldEcazDukePlayer(game, occupier).forces['carthag:11'], 2);
|
||||
assert.equal(homeworldEcazDukePlayer(game, occupier).forces[`${garrisonTerritory}:${garrisonSector}`], 2);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && homeworldEcazDukeClean(state));
|
||||
const nexus = structuredClone(game);
|
||||
@@ -176,7 +184,7 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
|
||||
if (allied) { act(occupier, { type: 'alliance', target: guild }); act(guild, { type: 'alliance', target: occupier }); }
|
||||
});
|
||||
assert.equal(homeworldEcazDukePlayer(game, occupier).ally, ally);
|
||||
const secondTerritory = allied ? 'tueks_sietch' : 'carthag', secondSector = allied ? 5 : 11;
|
||||
const secondTerritory = allied ? 'tueks_sietch' : garrisonTerritory, secondSector = allied ? 5 : garrisonSector;
|
||||
const recipient = ally ?? occupier, world = 'homeworld:ecaz';
|
||||
let invasion: ReturnType<typeof transition> | null = null, secondEntrant: ReturnType<typeof transition> | null = null;
|
||||
reach(state => state.turn === 2 && state.phase === 5 && homeworldEcazDukeClean(state));
|
||||
@@ -238,6 +246,7 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
|
||||
assert.deepEqual(game.battle.plans, {}); assert.equal(game.battle.revealed, false);
|
||||
assert.deepEqual(homeworldEcazDukeInventory(game), originalInventory);
|
||||
return { game, beforePower: structuredClone(game), advanced, allied, occupier, ally, ecaz, guild, opponent,
|
||||
harkonnen:options.harkonnen ?? false,garrisonTerritory,garrisonSector,
|
||||
recipient, dukeId: DUKE_VIDAL_ID, world, secondTerritory, secondSector,
|
||||
secondLocation: `${secondTerritory}:${secondSector}`,
|
||||
poison: { [occupier]: poisons[0].id, [guild]: poisons[1].id }, projectile: projectile.id,
|
||||
@@ -247,11 +256,13 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
|
||||
qualification: structuredClone(qualification), secondBattle,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual public Basic or Advanced preview Ecaz/E/G/A enables only Homeworlds, readies and starts. Only original unopened four-card/Spice positions and original scalar lotteries are selected; all33 physical cards and native pools remain.',
|
||||
'Native Ecaz setup places Basin6. G really bribes E one, deferred to Mentat; E really pays to ship two native counters to empty Carthag. Native Storm, auctions, Collection and Mentat run before the actual turn2 worm Nexus.',
|
||||
options.harkonnen ? 'Actual public Basic or Advanced preview Ecaz/E/G/A/H enables only Homeworlds, readies and starts. Original unopened six-card slots include Harkonnen native two-card start; all33 cards and original native armies remain.'
|
||||
: 'Actual public Basic or Advanced preview Ecaz/E/G/A enables only Homeworlds, readies and starts. Only original unopened four-card/Spice positions and original scalar lotteries are selected; all33 physical cards and native pools remain.',
|
||||
`Native Ecaz setup places Basin6. G really bribes E one, deferred to Mentat; E really pays to ship two native counters to empty ${garrisonTerritory}. Native Storm, auctions, Collection and Mentat run before the actual turn2 worm Nexus.`,
|
||||
allied ? 'E and G actually form their reciprocal alliance during that real Nexus; no alliance field is installed.'
|
||||
: 'The real Nexus is completed without an alliance; the later single eligible Duke recipient needs no manual confirmation.',
|
||||
'E submits a real owner-view typed four-normal invasion of Ecaz World. A separately pays to ship one native counter into the existing E Carthag group or G Tueks group, preserving a real second battle. Native Guild decisions and payments complete normally.',
|
||||
options.harkonnen ? 'E submits the original typed four-normal invasion of Ecaz World. Original Harkonnen ships one native counter into E Sietch Tabr2; original H10 Carthag stays untouched. Native Guild decisions and payments finish before the two actual battles.'
|
||||
: 'E submits a real owner-view typed four-normal invasion of Ecaz World. A separately pays to ship one native counter into the existing E Carthag group or G Tueks group, preserving a real second battle. Native Guild decisions and payments complete normally.',
|
||||
'The real World battle uses E native leader5/dial1 (one paid support in Advanced) against Ecaz native leader2/dial0 and its native World bonus. Actual loss/cleanup leaves three E visitors alone, zero Ecaz natives, and the living shared Duke. No force, wallet, custody, leader, clock or occupation receipt is installed.',
|
||||
'Stop at the actual next unsealed native Arrakis battle. Unallied source already receives the real automatic self-grant; allied source retains both actual recipient choices before either plan seals.',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DUKE_VIDAL_ID, dukeController, retireHomeworldDukeTenure } from '../game/duke-vidal';
|
||||
import { quoteHomeworldEcazDuke, releaseCapturedHomeworldDuke } from '../game/homeworld-ecaz-duke';
|
||||
import {
|
||||
createHomeworldEcazDukeCaptiveFixture, expireEcazDukeCaptive,
|
||||
useExpiredEcazDukeCaptive, controlledEcazDukeCaptiveDeparture,
|
||||
actEcazDukeCaptive as act, ecazDukeCaptivePlayer as p,
|
||||
ecazDukeCaptiveInventory as inventory,
|
||||
type HomeworldEcazDukeCaptiveFixture,
|
||||
} from './fixture-homeworld-ecaz-duke-captive';
|
||||
|
||||
let earned: HomeworldEcazDukeCaptiveFixture | undefined;
|
||||
const fixture = () => earned ??= createHomeworldEcazDukeCaptiveFixture();
|
||||
const reload = (g: Game): Game => structuredClone(g);
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = reload(game);
|
||||
assert.throws(() => applyAction(game,actor,action));
|
||||
assert.deepEqual(game,before);
|
||||
}
|
||||
function disc(game: Game) {
|
||||
assert.equal(game.dukeVidal!.leader.id,DUKE_VIDAL_ID);
|
||||
assert.equal(game.players.flatMap(p => p.leaders).filter(l => l.id === DUKE_VIDAL_ID).length,0);
|
||||
assert.deepEqual(inventory(game),fixture().source.originalInventory);
|
||||
}
|
||||
|
||||
void test('native occupied-Ecaz capture starts with original Harkonnen, actual World lease and unused-Duke lottery', () => {
|
||||
const f = fixture(), source = f.source;
|
||||
assert.match(f.disclosure,/Original public Advanced.*no installed occupation, lease or capture receipt/);
|
||||
assert.equal(source.initial.players.length,5);
|
||||
assert.equal(source.initial.players.find(p => p.id === f.captor)!.faction,'harkonnen');
|
||||
assert.equal(p(source.afterSetup,f.captor).forces['carthag:11'],10);
|
||||
assert.equal(p(source.afterSetup,f.captor).hand.length,2);
|
||||
assert.equal(source.originalInventory.length,33);
|
||||
assert.equal(p(source.duneShipment.after,f.owner).forces['sietch_tabr:14'],2);
|
||||
assert.equal(source.secondEntrant!.steps[0].actor,f.captor);
|
||||
assert.equal(source.qualification.player,f.owner);
|
||||
assert.equal(source.qualification.cause,'sole');
|
||||
assert.equal(f.beforeBattle.dukeVidal!.controller,f.owner);
|
||||
assert.equal(f.beforeBattle.dukeVidal!.source,'homeworldEcaz');
|
||||
assert.equal(f.beforeBattle.dukeVidal!.leader.usedAt,undefined);
|
||||
assert.ok(Object.values(f.plans).every(plan => plan.leader !== f.dukeId));
|
||||
assert.ok(f.candidates.includes(f.dukeId));
|
||||
assert.equal(f.selected.decision?.kind === 'capturedLeader' && f.selected.decision.leader,f.dukeId);
|
||||
assert.equal(f.game.dukeVidal!.leader.capturedBy,f.captor);
|
||||
assert.equal(dukeController(f.game.dukeVidal!),f.captor);
|
||||
assert.deepEqual(f.game.dukeVidal!.captureVisibility,{controller:f.owner,acquiredTurn:2,source:'homeworldEcaz'});
|
||||
assert.equal(f.game.dukeVidal!.leader.usedAt,undefined);
|
||||
assert.equal(f.sourceCount,f.sourceActions.length);
|
||||
assert.equal(f.captureActions.filter(step => step.action.type === 'battlePlan').length,2);
|
||||
assert.equal(f.captureActions.filter(step => step.action.type === 'decision' && step.action.accept === true).length,1);
|
||||
disc(f.game);
|
||||
});
|
||||
|
||||
void test('actual next-turn expiry retires occupied-Ecaz underlay while Harkonnen keeps the same unused living captive', () => {
|
||||
const f = fixture(), expired = expireEcazDukeCaptive(f), duke = expired.game.dukeVidal!;
|
||||
assert.equal(expired.game.turn,3);
|
||||
assert.ok(expired.actions.length > 0);
|
||||
assert.equal(duke.controller,null);
|
||||
assert.equal(duke.acquiredTurn,null);
|
||||
assert.equal(duke.source,null);
|
||||
assert.deepEqual(duke.captureVisibility,{controller:null,acquiredTurn:null,source:null});
|
||||
assert.equal(duke.leader.capturedBy,f.captor);
|
||||
assert.equal(duke.leader.concealed!.controller,f.owner);
|
||||
assert.equal(duke.leader.dead,false);
|
||||
assert.equal(duke.leader.deaths,f.game.dukeVidal!.leader.deaths);
|
||||
assert.equal(duke.leader.usedAt,undefined);
|
||||
assert.equal(dukeController(duke),f.captor);
|
||||
assert.deepEqual(expired.game.homeworldEcazDukeGrants,f.game.homeworldEcazDukeGrants);
|
||||
const offer = quoteHomeworldEcazDuke(expired.game);
|
||||
assert.equal(offer.owner,f.owner);
|
||||
assert.match(offer.blocked!,/captur|conceal|custody/i);
|
||||
assert.deepEqual(retireHomeworldDukeTenure(duke,expired.game.turn),duke);
|
||||
disc(expired.game);
|
||||
});
|
||||
|
||||
void test('real later captive-Duke battle use returns the expired shared disc set aside after a paid native entry', () => {
|
||||
const f = fixture(), expired = expireEcazDukeCaptive(f);
|
||||
const result = useExpiredEcazDukeCaptive(f,expired.game), game = result.game, duke = game.dukeVidal!;
|
||||
assert.ok(result.actions.some(step => step.actor === f.owner && step.action.type === 'ship' &&
|
||||
step.action.territory === f.territory && step.action.amount === 1));
|
||||
assert.ok(result.actions.some(step => step.actor === f.captor && step.action.type === 'battlePlan' &&
|
||||
step.action.leader === f.dukeId));
|
||||
assert.equal(result.beforeBattle.dukeVidal!.leader.capturedBy,f.captor);
|
||||
assert.equal(game.lastBattleContext!.winner,f.captor);
|
||||
assert.equal(duke.leader.id,f.dukeId);
|
||||
assert.equal(duke.leader.dead,false);
|
||||
assert.equal(duke.leader.deaths,f.game.dukeVidal!.leader.deaths);
|
||||
assert.equal(duke.leader.usedAt,f.territory);
|
||||
assert.equal(duke.leader.capturedBy,undefined);
|
||||
assert.equal(duke.leader.concealed,undefined);
|
||||
assert.equal(duke.captureVisibility,undefined);
|
||||
assert.equal(duke.controller,null);
|
||||
assert.equal(duke.source,null);
|
||||
assert.equal(duke.acquiredTurn,null);
|
||||
disc(game);
|
||||
});
|
||||
|
||||
void test('earned captive helper release restores the still-current qualified original occupied-Ecaz tenure', () => {
|
||||
const f = fixture(), game = reload(f.game), before = reload(game);
|
||||
const released = releaseCapturedHomeworldDuke(game,f.captor);
|
||||
assert.equal(game.turn,2);
|
||||
assert.equal(released.controller,f.owner);
|
||||
assert.equal(released.source,'homeworldEcaz');
|
||||
assert.equal(released.acquiredTurn,game.turn);
|
||||
assert.equal(released.leader.id,f.dukeId);
|
||||
assert.equal(released.leader.dead,false);
|
||||
assert.equal(released.leader.deaths,game.dukeVidal!.leader.deaths);
|
||||
assert.equal(released.leader.capturedBy,undefined);
|
||||
assert.equal(released.leader.concealed,undefined);
|
||||
assert.equal(released.captureVisibility,undefined);
|
||||
assert.deepEqual(game,before,'This focused helper exercise is not a claimed native early-release action.');
|
||||
});
|
||||
|
||||
void test('controlled conserved same-turn departure helper context cannot restore an ended occupied-Ecaz entitlement', () => {
|
||||
const f = fixture(), game = controlledEcazDukeCaptiveDeparture(f.game);
|
||||
// Explicit unit supplement: only present physical custody changed. No played
|
||||
// departure, occupation observation, lease/capture receipt or clock is added.
|
||||
assert.equal(game.turn,f.game.turn);
|
||||
assert.deepEqual(game.homeworldOccupationHistory,f.game.homeworldOccupationHistory);
|
||||
assert.deepEqual(game.homeworldEcazDukeGrants,f.game.homeworldEcazDukeGrants);
|
||||
assert.equal(quoteHomeworldEcazDuke(game).owner,null);
|
||||
const before = reload(game), released = releaseCapturedHomeworldDuke(game,f.captor);
|
||||
assert.equal(released.controller,null);
|
||||
assert.equal(released.source,null);
|
||||
assert.equal(released.acquiredTurn,null);
|
||||
assert.equal(released.leader.id,f.dukeId);
|
||||
assert.equal(released.leader.dead,false);
|
||||
assert.equal(released.leader.capturedBy,undefined);
|
||||
assert.equal(released.captureVisibility,undefined);
|
||||
assert.deepEqual(game,before);
|
||||
disc(game);
|
||||
});
|
||||
|
||||
void test('actual occupied-Ecaz acquisition remains blocked on the captive and foreign helper release rejects before mutation', () => {
|
||||
const f = fixture(), expired = expireEcazDukeCaptive(f), game = expired.game;
|
||||
const quote = quoteHomeworldEcazDuke(game); assert.ok(quote.event);
|
||||
reject(game,f.owner,{type:'homeworldDuke',event:quote.event,recipient:f.owner});
|
||||
const before = reload(game);
|
||||
assert.throws(() => releaseCapturedHomeworldDuke(game,f.owner));
|
||||
assert.deepEqual(game,before);
|
||||
assert.equal(dukeController(game.dukeVidal!),f.captor);
|
||||
disc(game);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer the actual occupied-Duke captured-leader choice legally', () => {
|
||||
const f = fixture();
|
||||
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
|
||||
const game = reload(f.selected), view = viewGame(game,f.captor);
|
||||
view.players.find(p => p.id === f.captor)!.bot = profile;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action,`${profile} needs the actual retained/executed captive decision.`);
|
||||
const next = act(game,f.captor,action);
|
||||
if (action.mode === 'execute') {
|
||||
assert.equal(next.dukeVidal!.leader.dead,true);
|
||||
assert.equal(next.dukeVidal!.leader.capturedBy,undefined);
|
||||
// The native Keep baseline includes the same automatic Carthag income.
|
||||
assert.equal(p(next,f.captor).spice,p(f.game,f.captor).spice+2);
|
||||
} else {
|
||||
assert.equal(action.mode,'keep');
|
||||
assert.equal(next.dukeVidal!.leader.capturedBy,f.captor);
|
||||
}
|
||||
disc(next);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { newPlayer } from '../game/engine';
|
||||
import {
|
||||
acquireDuke, captureDuke, createDukeVidal, dukeController, executeCapturedDuke,
|
||||
expireDuke, releaseCapturedDuke, retireHomeworldDukeTenure, returnedDukeCapture,
|
||||
} from '../game/duke-vidal';
|
||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { createHomeworldOccupationHistory, observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
import {
|
||||
createHomeworldEcazDukeReceipt, quoteHomeworldEcazDuke,
|
||||
quoteHomeworldEcazDukeLifecycle as lifecycle, releaseCapturedHomeworldDuke,
|
||||
type HomeworldEcazDukeContext as Context,
|
||||
} from '../game/homeworld-ecaz-duke';
|
||||
|
||||
const player = (g: Context, id: string) => {
|
||||
const p = g.players.find(p => p.id === id); assert.ok(p); return p;
|
||||
};
|
||||
function observe(g: Context, event: string) {
|
||||
g.homeworldOccupationHistory = observeHomeworldOccupation(g.homeworldOccupationHistory!,
|
||||
homeworldContext(g), g.homeworlds!.custody!, g.turn, 'change', event);
|
||||
}
|
||||
/** Controlled helper positions, not native battle/capture chronology. Original
|
||||
* typed pools and canonical observation create the occupied-source receipt;
|
||||
* real Duke capture/release/execution helpers then exercise its lifecycle. */
|
||||
function position(allied = false): Context {
|
||||
const players = (['ecaz', 'guild', 'atreides', 'harkonnen'] as const)
|
||||
.map(faction => newPlayer(faction, faction, faction));
|
||||
const context = homeworldContext({ advanced: true, players });
|
||||
const custody = createHomeworldCustody(context);
|
||||
const g: Context = { status: 'playing', advanced: true, turn: 1, players,
|
||||
homeworlds: { custody, historyVersion: 1 }, homeworldOccupationPreview: true,
|
||||
homeworldOccupationHistory: createHomeworldOccupationHistory(context, custody, 1, 'controlled-native-pools'),
|
||||
dukeVidal: createDukeVidal() };
|
||||
player(g, 'ecaz').tanks += player(g, 'ecaz').reserves;
|
||||
player(g, 'ecaz').reserves = 0;
|
||||
player(g, 'guild').reserves--;
|
||||
custody.visitors['homeworld:ecaz'] = { guild: { normal: 1, elite: 0 } };
|
||||
observe(g, 'controlled-guild-occupation');
|
||||
if (allied) {
|
||||
player(g, 'guild').ally = 'atreides';
|
||||
player(g, 'atreides').ally = 'guild';
|
||||
}
|
||||
const offer = quoteHomeworldEcazDuke(g);
|
||||
assert.ok(offer.event && offer.owner); assert.equal(offer.blocked, null);
|
||||
const recipient = allied ? 'atreides' : 'guild';
|
||||
g.homeworldEcazDukeGrants = [createHomeworldEcazDukeReceipt(g, offer.owner, offer.event, recipient)];
|
||||
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, recipient, 1, 'homeworldEcaz'),
|
||||
'harkonnen', recipient, 1, 'sietch_tabr');
|
||||
return g;
|
||||
}
|
||||
function depart(g: Context) {
|
||||
const group = g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild;
|
||||
player(g, 'guild').reserves += group.normal + group.elite;
|
||||
delete g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild;
|
||||
observe(g, 'controlled-guild-departure');
|
||||
}
|
||||
const noTenure = { controller: null, acquiredTurn: null, source: null };
|
||||
|
||||
void test('early captive return restores a still-qualified occupied Ecaz tenure without a second grant', () => {
|
||||
const g = position(), before = structuredClone(g);
|
||||
assert.equal(lifecycle(g).release, false);
|
||||
assert.match(lifecycle(g).blocked!, /captured, concealed or Ghola/);
|
||||
const released = releaseCapturedHomeworldDuke(g, 'harkonnen');
|
||||
assert.equal(released.controller, 'guild'); assert.equal(released.source, 'homeworldEcaz');
|
||||
assert.equal(released.acquiredTurn, 1); assert.equal(released.leader.capturedBy, undefined);
|
||||
assert.equal(released.captureVisibility, undefined); assert.equal(released.leader.concealed, undefined);
|
||||
assert.deepEqual(g, before);
|
||||
g.dukeVidal = released;
|
||||
assert.equal(quoteHomeworldEcazDuke(g).claimed?.recipient, 'guild');
|
||||
assert.match(quoteHomeworldEcazDuke(g).blocked!, /already chose/);
|
||||
});
|
||||
|
||||
void test('public turn-end retirement preserves the captive disc and later release cannot revive its lease', () => {
|
||||
const g = position(), held = g.dukeVidal!;
|
||||
held.leader.deaths = 2; held.leader.concealed!.deaths = 2;
|
||||
held.leader.usedAt = 'carthag';
|
||||
const before = structuredClone(held), retired = expireDuke(held, 1);
|
||||
assert.deepEqual(held, before);
|
||||
assert.deepEqual(retired.leader, held.leader);
|
||||
assert.equal(dukeController(retired), 'harkonnen');
|
||||
assert.equal(retired.controller, null); assert.equal(retired.source, null);
|
||||
assert.equal(retired.acquiredTurn, null); assert.deepEqual(retired.captureVisibility, noTenure);
|
||||
assert.equal(retired.leader.concealed?.controller, 'guild');
|
||||
g.dukeVidal = retired;
|
||||
const released = releaseCapturedHomeworldDuke(g, 'harkonnen', 2);
|
||||
assert.equal(dukeController(released), null); assert.equal(released.source, null);
|
||||
assert.equal(released.leader.usedAt, 'carthag'); assert.equal(released.leader.deaths, 2);
|
||||
assert.equal(released.leader.concealed, undefined);
|
||||
assert.throws(() => releaseCapturedHomeworldDuke(g, 'atreides', 2));
|
||||
});
|
||||
|
||||
void test('departure, replacement qualification and lost recipient are rechecked during actual captive return', () => {
|
||||
for (const reason of ['occupationEnded', 'occupationChanged', 'recipientChanged'] as const) {
|
||||
const g = position(reason === 'recipientChanged');
|
||||
if (reason === 'recipientChanged') player(g, 'atreides').ally = null;
|
||||
else {
|
||||
depart(g);
|
||||
if (reason === 'occupationChanged') {
|
||||
player(g, 'guild').reserves--;
|
||||
g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
|
||||
observe(g, 'controlled-new-guild-qualification');
|
||||
}
|
||||
}
|
||||
const before = structuredClone(g);
|
||||
assert.equal(lifecycle(g).reason, reason); assert.equal(lifecycle(g).release, true);
|
||||
assert.equal(lifecycle(g).blocked, null);
|
||||
const released = releaseCapturedHomeworldDuke(g, 'harkonnen');
|
||||
assert.equal(released.controller, null); assert.equal(released.source, null);
|
||||
assert.equal(released.leader.capturedBy, undefined);
|
||||
assert.deepEqual(g, before, 'release recheck is pure even before settled-boundary reconciliation');
|
||||
}
|
||||
});
|
||||
|
||||
void test('secret execution retains the same public lease until public expiry and retires its visibility only then', () => {
|
||||
const living = position(), executed = structuredClone(living);
|
||||
executed.dukeVidal = executeCapturedDuke(executed.dukeVidal!, 'harkonnen', 1);
|
||||
assert.equal(returnedDukeCapture(executed.dukeVidal), true);
|
||||
assert.deepEqual(lifecycle(executed), lifecycle(living), 'actual private death is not a lease-expiry reason');
|
||||
const original = structuredClone(executed.dukeVidal);
|
||||
const atDeadline = expireDuke(executed.dukeVidal, 1);
|
||||
assert.deepEqual(executed.dukeVidal, original);
|
||||
assert.deepEqual(atDeadline.leader, original.leader);
|
||||
assert.deepEqual(atDeadline.captureVisibility, noTenure);
|
||||
assert.equal(returnedDukeCapture(atDeadline), true);
|
||||
depart(living); depart(executed);
|
||||
assert.deepEqual(lifecycle(executed), lifecycle(living));
|
||||
assert.equal(lifecycle(executed).reason, 'occupationEnded');
|
||||
executed.dukeVidal = retireHomeworldDukeTenure(executed.dukeVidal, 1);
|
||||
assert.equal(returnedDukeCapture(executed.dukeVidal), true);
|
||||
assert.equal(executed.dukeVidal.leader.dead, true);
|
||||
assert.equal(executed.dukeVidal.leader.deaths, 2);
|
||||
assert.equal(executed.dukeVidal.leader.concealed?.controller, 'guild');
|
||||
});
|
||||
|
||||
void test('retired custody still permits the original captor execution and Ecaz-only return mechanisms', () => {
|
||||
const g = position();
|
||||
const retired = retireHomeworldDukeTenure(g.dukeVidal!, 1);
|
||||
const executed = executeCapturedDuke(retired, 'harkonnen', 1);
|
||||
assert.equal(returnedDukeCapture(executed), true);
|
||||
assert.deepEqual(executed.captureVisibility, noTenure);
|
||||
assert.equal(executed.leader.deaths, 2); assert.equal(executed.leader.concealed?.controller, 'guild');
|
||||
const revived = acquireDuke(executed, 'ecaz', 1, 'ecazNexus');
|
||||
assert.equal(revived.controller, 'ecaz'); assert.equal(revived.source, 'ecazNexus');
|
||||
assert.equal(revived.leader.deaths, 2); assert.equal(revived.leader.dead, false);
|
||||
assert.equal(revived.captureVisibility, undefined);
|
||||
const returned = releaseCapturedDuke(retired, 'harkonnen', 1);
|
||||
assert.equal(returned.controller, null); assert.equal(returned.leader.dead, false);
|
||||
assert.equal(returned.leader.id, retired.leader.id);
|
||||
});
|
||||
|
||||
void test('non-homeworld captive return and expiry keep their original tenure rules', () => {
|
||||
const g = position();
|
||||
for (const source of ['ecaz', 'moritani', 'ecazNexus', 'ally'] as const) {
|
||||
const captured = captureDuke(acquireDuke(createDukeVidal(), 'guild', 1, source),
|
||||
'harkonnen', 'guild', 1, 'sietch_tabr');
|
||||
g.dukeVidal = captured;
|
||||
assert.deepEqual(expireDuke(captured, 1), captured);
|
||||
assert.deepEqual(retireHomeworldDukeTenure(captured, 1), captured);
|
||||
assert.deepEqual(releaseCapturedHomeworldDuke(g, 'harkonnen'), releaseCapturedDuke(captured, 'harkonnen', 1));
|
||||
}
|
||||
});
|
||||
|
||||
void test('a retired captured or concealed disc still cannot receive a fresh occupied Ecaz grant', () => {
|
||||
const g = position();
|
||||
const held = retireHomeworldDukeTenure(g.dukeVidal!, 1);
|
||||
depart(g);
|
||||
player(g, 'guild').reserves--;
|
||||
g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
|
||||
observe(g, 'controlled-new-qualification');
|
||||
for (const state of [held, executeCapturedDuke(held, 'harkonnen', 1)]) {
|
||||
g.dukeVidal = state;
|
||||
assert.match(quoteHomeworldEcazDuke(g).blocked!, /captured, concealed or Ghola/);
|
||||
}
|
||||
// Deliberately invalid negative input: no legal foreign Duke revival is asserted.
|
||||
g.dukeVidal = createDukeVidal(); g.dukeVidal.leader.gholaBy = 'atreides';
|
||||
assert.match(quoteHomeworldEcazDuke(g).blocked!, /captured, concealed or Ghola/);
|
||||
});
|
||||
@@ -90,10 +90,14 @@ for (const advanced of [false, true]) void test(
|
||||
const f = fixture({ advanced, allied: true }); nativeSource(f); assert.ok(f.ally);
|
||||
const granted = grant(f, f.ally), battle = playBattle(f, granted.game, { killDuke: true });
|
||||
assert.equal(battle.game.dukeVidal?.leader.dead, true); assert.equal(battle.game.dukeVidal?.controller, null);
|
||||
const game = advance(battle.game, state => state.turn === 3 && state.phase === 4 && clean(state));
|
||||
let game = advance(battle.game, state => state.turn === 3 && state.phase === 4 && clean(state));
|
||||
assert.equal(game.dukeVidal?.leader.dead, true);
|
||||
reject(game, f.ally, { type: 'reviveLeader', leader: f.dukeId }, /Ecaz/i);
|
||||
assert.equal(game.dukeVidal?.leader.dead, true);
|
||||
// Preserve the foreign-revival denial above, then wait for Ecaz's actual
|
||||
// ordered Advanced opportunity before making its authorized revival.
|
||||
game = advance(game, state => state.phase === 4 && clean(state) &&
|
||||
(!state.revivalOrder || state.revivalOrder.remaining[0] === f.ecaz));
|
||||
const dead = structuredClone(game.dukeVidal!.leader), cash = player(game, f.ecaz).spice;
|
||||
const quote = quoteEcazDukeRevival(game, f.ecaz, false);
|
||||
assert.equal(quote.cost, 5); assert.ok(cash >= quote.cost, 'Original native Ecaz cash must fund its actual five-spice revival');
|
||||
|
||||
Reference in new issue
Block a user