Resolve Duke uncertainty on public events
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 19:30:00 +11:00
1 parent 57e7611468
commit 59cb336c80
14 files changed
+360 -25

No files matched your search

+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Resolving public Duke capture uncertainty — 29 October. Prototyped /
Partial.** Publicly established ordinary Duke battle, revival, acquisition or named
death outcomes clear the conservative capture marker, including in older games
without a marker. A later possible Harkonnen capture makes it uncertain again.
Same-recipient tenure conversion is available when history is clear. Twelve
related files pass 87 cases; some death and revival conditions are staged in
the focused fixtures. Other uncertainty resolution paths, full combinations
and deployed verification remain open.
[Scope](ECAZ_DUKE_ACQUISITION.md#public-resolution-of-capture-uncertainty--29-october-2026).
**Duke Ambassador capture uncertainty — 29 October. Prototyped / Partial.**
Fresh games track whether a public Harkonnen capture could have involved Duke,
independent of the secret selection. When capture history is uncertain, direct
+32 -8
View File
@@ -1,11 +1,34 @@
# Ecaz Ambassador: direct Duke acquisition
## Public resolution of capture uncertainty — 29 October 2026
**Prototyped / Partial.** A public event that establishes the actual unique
Duke disc clears the uncertainty receipt, including when an older game lacks
one. Supported events are a revealed ordinary Duke battle; a named paid, Ghola or
Tleilaxu Ambassador revival; Ecaz Nexus Cunning or occupied-Ecaz grant; a
public Moritani acquisition or Tanks resolution; and a named Residual Poison
or Terror assassination death. Canceled, declined or unrelated actions do not
clear it. A subsequent Harkonnen capture that could have included Duke records
new uncertainty. The existing eligibility and custody checks still apply.
An Advanced Harkonnen game with clear public history can now also use the
provisional same-recipient conversion from another temporary tenure. A current
ordinary Ecaz Ambassador tenure cannot be redundantly renewed. The public
history safeguard remains uniform when the marker is uncertain or absent.
Twelve related files pass 87 cases. The real battle fixture verifies a later
Ambassador grant and renewed uncertainty after capture. Other action fixtures
stage the marker; paid/Ghola and Residual Poison cases also stage the relevant
physical positions. More public resolution paths, complete combinations and
deployed verification remain open.
## Public capture uncertainty — 29 October 2026
**Prototyped / Partial.** A fresh game records whether a public Harkonnen
capture could have involved Duke, using the losing player's public identity,
revealed battle plan and projected Duke controller, never the selected captive.
Once that possibility arises it stays recorded. Direct Ambassador acquisition
Once that possibility arises it stays recorded until a supported public
resolution above. Direct Ambassador acquisition
and the automatic Advanced alliance loan are uniformly unavailable while the
record is uncertain, regardless of actual hidden custody. A game saved before
this receipt existed is also treated conservatively: its truncated history
@@ -17,9 +40,10 @@ ordinary direct grant and automatic new-ally loan; the original disc, reusable
token, existing custody checks and continuation remain. The same public
uncertainty guard protects already supported Harkonnen profiles. This
conservative restriction can block an otherwise available Duke after a
possible capture and currently does not clear itself; it is a provisional
online policy, not a publisher ruling. A more complete public resolution of
uncertainty, broader modules and old-game coverage remain open.
possible capture. The later public-resolution section above clears it when
specific public events establish the disc. The restriction is a provisional
online policy, not a publisher ruling; broader modules and old-game coverage
remain open.
Eight related files pass 62 cases. Genuine plain Advanced setup exercises both
grants, and an actual capture is replayed with controlled shuffle outcomes to
@@ -34,9 +58,9 @@ provisionally select the current recipient of a living Duke under another
temporary tenure, changing that same disc to the Ambassador's ordinary
until-battle tenure. Existing battle use and death history remain; no leader,
resource or extra use is created. A recipient already holding the ordinary
Ambassador tenure cannot trigger a redundant renewal. Same-recipient choices
in Advanced Harkonnen tables remain uniformly unavailable so their offer does
not disclose an otherwise concealed capture. Other exceptional custody and
Ambassador tenure cannot trigger a redundant renewal. The later public
uncertainty policy above permits same-recipient conversion in Advanced
Harkonnen games when their public capture history is clear. Other exceptional custody and
occupied-Ecaz priority retain their existing restrictions.
Publisher E3 printed p. 7 describes the Ambassador grant until battle; printed
@@ -178,7 +202,7 @@ The complete Ambassador gameplay explanation remains readable in the token inspe
| Facet | Evidence | Remaining |
| --- | --- | --- |
| Implementation | Shared self/ally quote, atomic real entry, token return, original movement/worm suffix, same-disc battle/turn lifecycle | Advanced Harkonnen same-holder and wider revival/capture/occupation combinations |
| Implementation | Shared self/ally quote, atomic real entry, token return, original movement/worm suffix, same-disc battle/turn lifecycle | Wider revival/capture/occupation combinations and remaining public uncertainty resolution |
| 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 |
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Public resolution of Duke capture uncertainty — 29 October 2026
A revealed ordinary Duke battle or named public revival, acquisition or death resolves the prior
capture uncertainty; a later possible Harkonnen capture can restore it. This
is a provisional online policy and does not infer legacy capture history.
[Scope](ECAZ_DUKE_ACQUISITION.md#public-resolution-of-capture-uncertainty--29-october-2026).
## Duke Ambassador capture uncertainty — 29 October 2026
Fresh games record whether a public capture could have involved Duke, without
+1 -1
View File
@@ -11,7 +11,7 @@ export type DukeCapturePublicSource = {
/** Called for every completed Harkonnen capture, regardless of its concealed
* selection. Once the public cannot rule out Duke, keep the uncertainty until
* a separate public resolution. An unstamped legacy game remains unstamped:
* a separate public resolution explicitly clears it. An unstamped legacy game remains unstamped:
* its past capture history cannot be reconstructed from current state. */
export function nextDukeCaptureUncertainty(
marker: boolean | undefined,
+1 -10
View File
@@ -13,7 +13,7 @@ export type EcazDukeContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
dukeVidal?: DukeState;
dukeCaptureUncertain?: boolean;
};
type Failure = 'context' | 'custody' | 'table' | 'captureUncertain' | 'alreadyControlled' | 'sameHolderTable';
type Failure = 'context' | 'custody' | 'table' | 'captureUncertain' | 'alreadyControlled';
export class EcazDukeAcquisitionError extends Error {
constructor(
message: string,
@@ -34,8 +34,6 @@ const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
const tableBlock =
'Duke Vidal acquisition with Advanced Harkonnen is still being implemented.';
const sameHolderTableBlock =
'Same-holder Duke acquisition with Advanced Harkonnen is not available in this preview.';
const alreadyBlock =
'Ecaz already controls Duke Vidal through its Ambassador.';
const allyAlreadyBlock = 'The chosen beneficiary already controls Duke Vidal through the Ambassador.';
@@ -97,12 +95,6 @@ export function quoteEcazDukeAcquisition(
whole(duke.leader.deaths),
'The shared Duke Vidal disc has invalid physical identity or history.',
);
// Harkonnen may privately capture Duke without changing his public prior
// controller. Keep same-holder availability independent of that hidden
// custody until a public-safe capture continuation is implemented.
requireDuke(duke.controller !== recipientId || !g.advanced ||
!g.players.some(player => player.faction === 'harkonnen'),
sameHolderTableBlock, 'sameHolderTable');
// A direct Ambassador grant is already in force for this recipient. Other
// sources may be converted to this until-battle tenure without moving or
// recreating the disc. Check public tenure before private custody details.
@@ -144,7 +136,6 @@ export function ecazDukeAcquisitionBlock(
if (error instanceof EcazDukeAcquisitionError) {
if (error.reason === 'captureUncertain') return dukeCaptureUncertaintyBlock(g);
if (error.reason === 'table') return tableBlock;
if (error.reason === 'sameHolderTable') return sameHolderTableBlock;
if (error.reason === 'alreadyControlled')
return recipientId === ownerId ? alreadyBlock : allyAlreadyBlock;
}
+3 -3
View File
@@ -1,8 +1,8 @@
import { selectedPhaseModuleRoster, type FactionModuleProfile } from './faction-module-profile';
/** Public, monotonic uncertainty about a possible concealed Duke capture.
* Fresh games explicitly begin false. True, absent, or malformed values never
* establish safety; in particular, legacy saves are not silently converted. */
/** Public uncertainty about a possible concealed Duke capture. Fresh games
* explicitly begin false; only a public resolution may restore false. True,
* absent, or malformed values never establish safety. */
export type DukeCapturePolicyContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
advanced: boolean;
players: readonly { faction: string }[];
+14 -1
View File
@@ -3887,6 +3887,7 @@ function playNexusEcazDuke(g: Game, p: Player, action: Action) {
(g.nexusEcazDukeEvents ??= []).push(receipt.event);
log(g, `${p.name} spent Ecaz Nexus Cunning to take Duke Prad Vidal for this turn. The shared leader remains physically unique and returns after use or at turn end.`,
{ faction: p.faction, name: 'Ecaz Nexus Cunning' });
g.dukeCaptureUncertain = false;
}
function playMoritaniBetrayal(g: Game, p: Player, action: Action) {
@@ -27441,6 +27442,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
}
log(g, `${p.name} revived ${name} for free through the Tleilaxu Ambassador.${selected === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ''} The ordinary leader allowance is unchanged.`,
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' });
if (selected === DUKE_VIDAL_ID) g.dukeCaptureUncertain = false;
finishAmbassadorRevival(g, p, returned);
return;
}
@@ -28058,6 +28060,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
finishLeaderSkillCustody(g);
p.spice += bounty;
log(g, `${victim.name} was assassinated; ${p.name} received ${bounty} spice.`);
if (victim.id === DUKE_VIDAL_ID) g.dukeCaptureUncertain = false;
} else {
log(g, `${p.name} revealed Assassination, but the entrant controlled no living leader. No leader died and no spice was awarded.`);
}
@@ -31308,6 +31311,7 @@ function claimHomeworldEcazDuke(g: Game, actor: string, event: unknown, recipien
(g.homeworldEcazDukeGrants ??= []).push(receipt);
log(g, `${getPlayer(g, actor).name} granted the living Duke Vidal to ${getPlayer(g, recipient).name} through occupied Ecaz. This provisional grant permits one battle and ends unused at turn end; only Ecaz may revive him.`,
{ faction: getPlayer(g, actor).faction, name: 'Occupied Ecaz Duke grant' });
g.dukeCaptureUncertain = false;
}
function reconcileHomeworldEcazDuke(g: Game) {
if (g.status !== 'playing' || !g.homeworldOccupationPreview || !byFaction(g, 'ecaz') || homeworldEcazDukeBoundaryBlocked(g)) return;
@@ -32216,6 +32220,7 @@ function playResidualPoison(
`${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`,
{ faction: p.faction, name: 'Residual Poison' },
);
if (victim.id === DUKE_VIDAL_ID) g.dukeCaptureUncertain = false;
if (semutaAdvancedNativeProducerEnabled(g)) {
const continuation: ResidualPoisonDiscardContinuation = {
kind: 'residualPoisonDiscard', player: p.id, target: target.id,
@@ -33118,6 +33123,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
g,
`${p.name} revived ${l.name} with Ghola.${l.id === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ' The leader may fight again this turn;'} No spice or normal leader-revival allowance was spent.`,
);
if (l.id === DUKE_VIDAL_ID) g.dukeCaptureUncertain = false;
} else {
const n = integer(
action.amount ?? Math.min(5, p.tanks),
@@ -35627,8 +35633,12 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
if (choices.length)
g.pendingTech = { player: winner.id, loser: loser.id, choices };
}
if (g.dukeVidal && [ap.leader, dp.leader].includes(DUKE_VIDAL_ID))
if (g.dukeVidal && [ap.leader, dp.leader].includes(DUKE_VIDAL_ID)) {
g.dukeVidal = consumeDuke(g.dukeVidal);
// Revealed battle plans publicly establish the unique disc's location.
// A subsequent Harkonnen capture may make the result uncertain again.
if (b.revealed) g.dukeCaptureUncertain = false;
}
g.lastBattle = [a.id, d.id];
const battleEvent = b.event ?? g.pendingAuditor?.event ?? crypto.randomUUID();
const inspection: NexusChoamInspectionReceipt | null =
@@ -37281,6 +37291,7 @@ function finishRevival(
`${p.name} revived ${leader!.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`,
{ faction: p.faction, name: 'Revival' },
);
if (sharedDuke) g.dukeCaptureUncertain = false;
}
}
g.pendingRevival = null;
@@ -38500,6 +38511,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
} else if (!canceled && !g.dukeVidal.leader.dead) {
g.dukeVidal = acquireDuke(g.dukeVidal, response.owner, g.turn, 'moritani');
log(g, `${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`);
g.dukeCaptureUncertain = false;
} else if (!canceled) {
// A qualified public acquisition resolves the concealed disc's availability;
// never revive a privately executed Duke or leak it through a private-only gate.
@@ -38507,6 +38519,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
'The unavailable acquisition lost the original executed Duke return.');
g.dukeVidal = consumeDuke(g.dukeVidal);
log(g, `${getPlayer(g, response.owner).name}'s Duke acquisition resolved with the shared disc in the Tanks. No acquisition occurred.`);
g.dukeCaptureUncertain = false;
} else
log(g, 'Karama prevented Moritani from acquiring Duke Vidal this turn.');
nextPhase(g);
+2 -2
View File
@@ -1727,7 +1727,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 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.',
'In Advanced games with Harkonnen, a public capture that could have involved Duke makes the direct grant and automatic loan unavailable for the rest of the current prototype, regardless of which leader was secretly captured. Older games without this capture record use the same conservative guard. Before such uncertainty, supported plain faction games can use both grants. This provisional restriction protects the hidden captive identity and may prevent an otherwise legal grant.',
'In Advanced games with Harkonnen, a public capture that could have involved Duke makes the direct grant and automatic loan unavailable, regardless of which leader was secretly captured. A public event establishing the actual Duke, such as a revealed ordinary Duke battle or named revival, clears this uncertainty; a later possible capture can restore it. Older games without the capture record remain guarded until a supported public resolution. This provisional restriction protects the hidden captive identity and may prevent an otherwise legal grant.',
'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.',
'In supported Basic games with Ecaz and Moritani, a single entry that earns both an Ambassador and Terror opportunity retains both in their existing provisional storm order, including selected native factions and the independent Ecaz cards. A Fremen relocation or Guild shipment from the Ambassador resolves its own child arrival before the original queue continues. If that child reveals the same Terror token, it cannot trigger again for the original entrant; if it is left hidden, the original opportunity remains. Unsupported module interactions remain guarded.',
'Supported Advanced games with both Ecaz and Moritani and other native faction decks also offer both entry reactions in provisional storm order. CHOAM may prevent an eligible move first; otherwise the move happens once before the token decisions. Fremen and Guild Ambassador effects that create another arrival remain unavailable in this Advanced overlap.',
@@ -4157,7 +4157,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 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. A current recipient under a different temporary tenure may provisionally renew control through the Ambassador; Advanced Harkonnen same-holder cases remain guarded. 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. A current recipient under a different temporary tenure may provisionally renew control through the Ambassador, including with Advanced Harkonnen when public capture history is clear. 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.',
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { createHomeworldEcazDukeFixture, actHomeworldEcazDuke, homeworldEcazDukePhysical } from './fixture-homeworld-ecaz-duke';
for (const legacy of [false, true]) void test(
`public occupied Ecaz grant clears ${legacy ? 'legacy' : 'uncertain'} Duke capture history`, () => {
// The original occupation, alliance and choice are earned; only the
// previous public uncertainty receipt is staged for this continuation.
const fixture = createHomeworldEcazDukeFixture({ advanced: true, allied: true });
const source = structuredClone(fixture.game), beforePhysical = structuredClone(homeworldEcazDukePhysical(source));
if (legacy) delete source.dukeCaptureUncertain;
else source.dukeCaptureUncertain = true;
const offer = viewGame(source, fixture.occupier).homeworldEcazDuke;
assert.ok(offer); assert.equal(offer.blocked, null); assert.ok(fixture.ally);
const action = { type: 'homeworldDuke', event: offer.event, recipient: fixture.ally };
const before = structuredClone(source);
assert.throws(() => applyAction(source, fixture.occupier, { ...action, event: `${offer.event}:stale` }));
assert.deepEqual(source, before, 'A rejected grant must not clear uncertainty');
const done = actHomeworldEcazDuke(source, fixture.occupier, action);
assert.equal(done.dukeCaptureUncertain, false);
assert.equal(done.dukeVidal?.controller, fixture.ally);
assert.equal(done.dukeVidal?.source, 'homeworldEcaz');
assert.equal(done.dukeVidal?.leader.id, fixture.dukeId);
assert.ok(done.log.some(entry => entry.text.includes('granted the living Duke Vidal')));
assert.deepEqual(homeworldEcazDukePhysical(done), beforePhysical);
});
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { acquireDuke, createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
import { richeseCards } from '../game/richese-cards';
import { moritaniTerrorAssassinationPool } from '../game/moritani-terror-assassination';
import { createMoritaniDukeCaptureFixture, revealMoritaniTerrorVictim } from './fixture-moritani-duke-capture';
function mark(game: Game, marker: 'uncertain' | 'legacy') {
if (marker === 'uncertain') game.dukeCaptureUncertain = true;
else delete game.dukeCaptureUncertain;
}
/** Staged ordinary pre-battle source based on the Residual Poison review
* fixture. One real card and the one shared Duke are placed in their physical
* zones; native leader deaths are controlled so the random pool is singleton. */
function residual(victim: 'duke' | 'native'): Game {
let game = createGame('DUKERESIDUAL', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
game.players.push(newPlayer('g', 'Guild', 'guild'), newPlayer('e', 'Emperor', 'emperor'));
Object.assign(game, {status:'playing',phase:6,turn:2,active:'r',order:['r','g','e'],storm:18,
deck:baseDeck(),richeseCache:richeseCards()});
for (const player of game.players) { player.hand = []; player.spice = 10; player.forces = {}; player.reserves = 20; }
for (const player of game.players.slice(0, 2)) { player.forces = {'arrakeen:10':2}; player.reserves = 18; }
const cache = game.richeseCache!, index = cache.findIndex(card => card.effect === 'residualPoison');
assert.ok(index >= 0); game.players[0].hand.push(cache.splice(index, 1)[0]);
const guild = game.players[1];
for (const leader of guild.leaders) { leader.dead = true; leader.deaths = 1; }
if (victim === 'native') { guild.leaders[0].dead = false; guild.leaders[0].deaths = 0; }
game.dukeVidal = acquireDuke(createDukeVidal(), 'g', 2, 'moritani');
if (victim === 'native') game.dukeVidal.leader.usedAt = 'carthag';
game = applyAction(game, 'r', {type:'chooseBattle', target:'g', territory:'arrakeen'});
return game;
}
for (const state of ['uncertain', 'legacy'] as const) {
void test(`public Moritani Terror Duke death resolves ${state} uncertainty`, () => {
const f = createMoritaniDukeCaptureFixture({terror:true});
mark(f.game, state);
const {after} = revealMoritaniTerrorVictim(f, DUKE_VIDAL_ID);
assert.equal(after.dukeVidal!.leader.dead, true);
assert.equal(after.dukeCaptureUncertain, false);
assert.ok(after.log.some(entry => entry.text.includes('Duke Prad Vidal was assassinated')));
});
void test(`Moritani Terror killing another leader preserves ${state} uncertainty`, () => {
const f = createMoritaniDukeCaptureFixture({terror:true});
mark(f.game, state);
const pool = moritaniTerrorAssassinationPool(f.game, f.captor);
const other = pool.find(leader => leader.id !== DUKE_VIDAL_ID); assert.ok(other);
const {after} = revealMoritaniTerrorVictim(f, other.id);
assert.equal(after.dukeCaptureUncertain, state === 'uncertain' ? true : undefined);
assert.equal(after.dukeVidal!.leader.dead, false);
});
for (const victim of ['duke', 'native'] as const) {
void test(`public Residual Poison ${victim} death ${victim === 'duke' ? 'resolves' : 'preserves'} ${state} uncertainty`, () => {
const game = residual(victim); mark(game, state);
const after = applyAction(game, 'r', {type:'card',card:'richese-residual-poison',target:'g',event:game.battle!.event});
assert.equal(after.dukeCaptureUncertain, victim === 'duke' ? false : state === 'uncertain' ? true : undefined);
assert.equal(after.dukeVidal!.leader.dead, victim === 'duke');
assert.equal(after.log.some(entry => entry.text.includes('Duke Prad Vidal was randomly selected')), victim === 'duke');
});
}
}
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, type Game } from '../game/engine';
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
import { createNexusDukeDeathFixture } from './fixture-nexus-duke-capture';
import { cancelMoritaniDukeReclaim, createMoritaniDukeCaptureFixture,
reclaimMoritaniDuke } from './fixture-moritani-duke-capture';
/** Both marker states occur after a publicly possible Duke capture: true on
* fresh games, absent on older saves whose complete history is unavailable. */
function marker(game: Game, value: 'uncertain' | 'legacy') {
if (value === 'uncertain') game.dukeCaptureUncertain = true;
else delete game.dukeCaptureUncertain;
}
for (const state of ['uncertain', 'legacy'] as const) {
void test(`public Ecaz Cunning resolves ${state} Duke uncertainty`, () => {
// Native setup, an actual battle death, and the original Nexus draw.
const fixture = createNexusDukeDeathFixture(), game = fixture.window;
marker(game, state);
const done = applyAction(game, fixture.owner, fixture.action);
assert.equal(done.dukeCaptureUncertain, false);
assert.equal(done.dukeVidal?.leader.id, DUKE_VIDAL_ID);
assert.equal(dukeController(done.dukeVidal!), fixture.owner);
assert.equal(done.dukeVidal?.source, 'ecazNexus');
assert.ok(done.log.some(entry => entry.text.includes('spent Ecaz Nexus Cunning to take Duke')));
});
for (const execute of [false, true]) {
void test(`public Moritani ${execute ? 'Tanks outcome' : 'acquisition'} resolves ${state} Duke uncertainty`, () => {
// Native setup, actual capture and actual Moritani response. The
// original physical lottery entropy is controlled by the shared fixture.
const f = createMoritaniDukeCaptureFixture({ execute });
marker(f.game, state);
const { after } = reclaimMoritaniDuke(f);
assert.equal(after.dukeCaptureUncertain, false);
assert.equal(after.dukeVidal?.leader.id, DUKE_VIDAL_ID);
assert.equal(after.dukeVidal?.leader.dead, execute);
assert.equal(dukeController(after.dukeVidal!), execute ? null : f.owner);
assert.ok(after.log.some(entry => execute
? entry.text.includes('Duke acquisition resolved with the shared disc in the Tanks')
: entry.text.includes('acquired Duke Vidal for one battle')));
});
}
void test(`canceled Moritani acquisition preserves ${state} Duke uncertainty`, () => {
const f = createMoritaniDukeCaptureFixture({ karama: true });
marker(f.game, state);
const { after } = cancelMoritaniDukeReclaim(f);
assert.equal(after.dukeCaptureUncertain, state === 'uncertain' ? true : undefined);
assert.equal(dukeController(after.dukeVidal!), f.captor);
assert.ok(after.log.some(entry => entry.text.includes('Karama prevented Moritani from acquiring Duke')));
});
}
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
import { newRevivalRules } from '../game/revival';
import { actDukeAmbassador, createDukeAmbassadorFixture } from './fixture-duke-ambassador';
/** Stage the public dead Duke and capture marker in an Advanced roster; the
* actual return uses the native action. */
function revivalSource(legacy = false) {
const game = createGame('DUKEPUBLICREVIVAL', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']);
game.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('g', 'Guild', 'guild'));
Object.assign(game, { status: 'playing', turn: 3, phase: 4, order: ['ec', 'm', 'g'],
revivalRules: newRevivalRules() });
game.dukeVidal = createDukeVidal();
Object.assign(game.dukeVidal.leader, { dead: true, deaths: 1 });
game.players[0].spice = 10;
game.players[0].leaders.slice(0, 4).forEach(leader => Object.assign(leader, { dead: true, deaths: 1 }));
game.deck = baseDeck();
if (legacy) delete game.dukeCaptureUncertain;
else game.dukeCaptureUncertain = true;
return game;
}
for (const legacy of [false, true]) {
void test(`public paid Duke revival clears ${legacy ? 'legacy' : 'uncertain'} capture history`, () => {
const source = revivalSource(legacy), initial = structuredClone(source), before = source.players[0].spice;
assert.throws(() => applyAction(source, 'ec', { type: 'reviveLeader', leader: 'not-a-leader' }));
assert.deepEqual(source, initial, 'Rejected action does not clear uncertainty or spend spice');
const done = applyAction(source, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.equal(done.dukeCaptureUncertain, false);
assert.equal(done.dukeVidal!.leader.dead, false);
assert.equal(done.dukeVidal!.controller, null);
assert.equal(done.players[0].spice, before - 5);
assert.ok(done.log.some(entry => entry.text.includes('revived Duke Prad Vidal')));
});
void test(`public Ghola Duke revival clears ${legacy ? 'legacy' : 'uncertain'} capture history`, () => {
const source = revivalSource(legacy);
const index = source.deck.findIndex(card => card.effect === 'ghola'); assert.ok(index >= 0);
const card = source.deck.splice(index, 1)[0]; source.players[0].hand.push(card);
const done = applyAction(source, 'ec', { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
assert.equal(done.dukeCaptureUncertain, false);
assert.equal(done.dukeVidal!.leader.dead, false);
assert.equal(done.dukeVidal!.controller, null);
assert.ok(done.log.some(entry => entry.text.includes('revived Duke Prad Vidal with Ghola')));
assert.ok(done.discard.some(item => item.id === card.id));
});
}
void test('public Tleilaxu Ambassador Duke return clears uncertainty, but decline does not', () => {
const f = createDukeAmbassadorFixture(), source = structuredClone(f.currentGrant);
source.dukeCaptureUncertain = true;
const declined = actDukeAmbassador(structuredClone(source), f.owner,
{ type: 'decision', event: f.event, decline: true });
assert.equal(declined.dukeCaptureUncertain, true);
assert.equal(declined.dukeVidal!.leader.dead, true);
const done = actDukeAmbassador(source, f.owner,
{ type: 'decision', event: f.event, leader: DUKE_VIDAL_ID });
assert.equal(done.dukeCaptureUncertain, false);
assert.equal(done.dukeVidal!.leader.dead, false);
assert.equal(done.dukeVidal!.controller, null);
assert.ok(done.log.some(entry => entry.text.includes('revived Duke Prad Vidal for free through the Tleilaxu Ambassador')));
});
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { advanceNexusDuke, createNexusDukeCaptureFixture, nexusDukeClean, quietPlan,
sealNexusDukeBattle, stepNexusDuke, type NexusDukeSeatPlan } from './fixture-nexus-duke-capture';
/** Genuine Advanced Nexus faction setup and real Duke battle. The marker is
* explicitly staged to represent either a prior ambiguous capture or a legacy
* save; original leaders, forces, cards and parent decisions are not staged. */
function publicDukeBattle(marker: true | undefined) {
const source = createNexusDukeCaptureFixture({variant:'used'});
const game = structuredClone(source.battle.after);
if (marker === undefined) delete game.dukeCaptureUncertain;
else game.dukeCaptureUncertain = marker;
const sealed = source.sealed.battle; assert.ok(sealed?.revealed);
const plans: NexusDukeSeatPlan[] = [source.owner, source.captor].map(actor => {
const plan = sealed.plans[actor]; assert.ok(plan);
return {actor, leader:plan.leader, dial:plan.dial, support:plan.support,
...(plan.weapon ? {weapon:plan.weapon} : {}), ...(plan.defense ? {defense:plan.defense} : {})};
});
const plan = quietPlan(game, {owner:source.owner, captor:source.captor, entrant:source.entrant,
others:source.others, token:source.token, destination:source.destination, capture:'decline'});
const revealed = sealNexusDukeBattle(game, plan, [], plans);
assert.equal(revealed.battle?.plans[source.owner]?.leader, DUKE_VIDAL_ID);
const pending = advanceNexusDuke(revealed, state => state.decision?.kind === 'captureOffer', plan);
assert.equal(pending.dukeCaptureUncertain, false, 'Public Duke battle resolves earlier uncertainty');
return {source, pending, plan};
}
for (const marker of [true, undefined] as const) void test(`public Duke battle clears ${marker === undefined ? 'legacy unknown' : 'prior capture uncertainty'} before a later Ambassador grant`, () => {
const {source, pending, plan} = publicDukeBattle(marker);
assert.ok(pending.decision?.kind === 'captureOffer');
let game = stepNexusDuke(pending, plan, {actor:pending.decision.player, action:{type:'decision',accept:false}});
game = advanceNexusDuke(game, state => !state.battle && !state.pendingCapture && nexusDukeClean(state), plan);
assert.equal(game.dukeCaptureUncertain, false);
const future = quietPlan(game, {owner:source.owner, captor:source.captor, entrant:source.entrant,
others:source.others, place:true});
game = advanceNexusDuke(game, state => state.ecazAmbassadors!.tokens.some(token => token.id === future.token && token.zone === 'placed'), future);
future.place = false;
future.ship = {[source.entrant]:{territory:future.destination, amount:1}};
game = advanceNexusDuke(game, state => state.pendingAmbassador?.stage === 'offer' && state.pendingAmbassador.token === future.token, future);
const view = viewGame(game, source.owner); assert.equal(view.ambassadorEntry?.dukeAcquisition?.blocked, null);
const event = game.pendingAmbassador!.event;
const after = applyAction(game, source.owner, {type:'decision',event,trigger:true,beneficiary:source.owner,choice:'duke'});
assert.equal(after.dukeVidal?.controller, source.owner);
assert.equal(after.dukeVidal?.source, 'ecaz');
assert.equal(after.ecazAmbassadors!.tokens.find(token => token.id === future.token)?.zone, 'supply');
});
void test('a new Duke-possible capture after a public Duke battle restores uncertainty', () => {
const {pending, plan} = publicDukeBattle(true);
assert.ok(pending.decision?.kind === 'captureOffer');
let game = stepNexusDuke(pending, plan, {actor:pending.decision.player, action:{type:'decision',accept:true}});
game = advanceNexusDuke(game, state => state.decision?.kind === 'capturedLeader', plan);
assert.equal(game.dukeCaptureUncertain, true);
});
+22
View File
@@ -77,6 +77,9 @@ void test('Advanced Harkonnen does not disclose a concealed same-controller capt
// two controlled projections differ only in hidden Duke capture custody.
game.players[0].ally = null; game.players[1].ally = null;
game.ecazTreachery = true; game.discoveryEnabled = true;
// The same public capture event must set uncertainty regardless of which
// leader was privately selected. Legacy saves have no reliable marker.
game.dukeCaptureUncertain = true;
const hidden = structuredClone(game);
hidden.dukeVidal!.leader.capturedBy = 'mor';
hidden.dukeVidal!.leader.concealed = { captor: 'mor', controller: 'ec', dead: false, deaths: 0 };
@@ -86,6 +89,25 @@ void test('Advanced Harkonnen does not disclose a concealed same-controller capt
assert.equal(ecazDukeAcquisitionBlock(hidden, 'ec', 'ec'), publicReason);
assert.throws(() => quoteEcazDukeAcquisition(game, 'ec', 'ec'));
assert.throws(() => quoteEcazDukeAcquisition(hidden, 'ec', 'ec'));
delete game.dukeCaptureUncertain;
delete hidden.dukeCaptureUncertain;
assert.equal(ecazDukeAcquisitionBlock(hidden, 'ec', 'ec'), ecazDukeAcquisitionBlock(game, 'ec', 'ec'));
});
void test('Advanced Harkonnen allows same-controller conversion when public capture history is clear', () => {
const game = position('ecazNexus');
game.advanced = true; game.players[1].faction = 'harkonnen';
game.players[0].ally = null; game.players[1].ally = null;
game.ecazTreachery = true; game.discoveryEnabled = true;
assert.equal(game.dukeCaptureUncertain, false);
const before = structuredClone(game), quote = quoteEcazDukeAcquisition(game, 'ec', 'ec');
assert.equal(ecazDukeAcquisitionBlock(game, 'ec', 'ec'), null);
assert.deepEqual(quote.leader, before.dukeVidal!.leader);
assert.deepEqual([quote.controller, quote.source, quote.acquiredTurn], ['ec', 'ecaz', game.turn]);
assert.deepEqual(game, before);
game.dukeVidal!.source = 'ecaz';
assert.match(ecazDukeAcquisitionBlock(game, 'ec', 'ec')!, /already controls/);
assert.throws(() => quoteEcazDukeAcquisition(game, 'ec', 'ec'));
});
for (const advanced of [false, true]) {