diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5ca88f8b..dfca8280 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Concealed Duke and Ghola guidance — 29 October. Prototyped / Partial.** +Ecaz's Ghola choices now use the same projected Duke state as ordinary revival, +so secretly executing another player's Duke captive no longer changes that +choice list. A staged physical-custody regression fails without the fix; four +related files pass 27 cases. The related cohort fixture now waits for Ecaz's +actual Advanced Revival opportunity. A separate potential disclosure in the +Ambassador acquisition availability remains open in admitted Harkonnen +profiles; the plain profile stays guarded. Wider capture assurance and deployed +verification remain open. +[Scope](DUKE_VIDAL_RULES.md#concealed-duke-ghola-guidance--29-october-2026). + **Caladan occupier's auction knowledge — 29 October. Prototyped / Partial.** An eligible occupier can answer the existing Truthtrance auction-inspection fact using its actual current ordinary or Advanced Black Market inspection, diff --git a/docs/DUKE_VIDAL_RULES.md b/docs/DUKE_VIDAL_RULES.md index 70b8fcff..774ef186 100644 --- a/docs/DUKE_VIDAL_RULES.md +++ b/docs/DUKE_VIDAL_RULES.md @@ -1,5 +1,24 @@ # Duke Prad Vidal lifecycle +## Concealed Duke Ghola guidance — 29 October 2026 + +**Prototyped / Partial.** Ecaz's private Ghola choice list now uses the Duke +state projected to Ecaz, just as its ordinary revival guidance does. A secret +execution of a Duke captured from another controller therefore does not change +that list before Ecaz can know the death. A publicly dead Duke remains offered; +actual Ghola actions continue to validate authoritative physical custody. + +The focused test stages real Duke capture/execution transitions and a conserved +physical Ghola. It fails on the prior unprojected guidance and passes with the +fix; four related files pass 27 cases. Two older cohort journeys initially +failed on the prior code too; their fixture now waits for Ecaz's real ordered +Advanced Revival opportunity. A separate potential disclosure remains: in +admitted Advanced Harkonnen profiles, the Ambassador acquisition descriptor +uses actual Duke custody and may distinguish a secretly captured Duke from a +publicly equivalent free Duke. Plain Advanced Ecaz/Harkonnen acquisition stays +guarded until a public-safe solution is implemented. Broader capture assurance +and deployed verification remain open. + ## Occupied Ecaz grant — 26 October 2026 The occupied Homeworld now connects its living shared-Duke recipient choice, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 8325ccf6..c187fd5b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Concealed Duke and Ghola guidance — 29 October 2026 + +Ecaz's Ghola guidance uses its projected Duke state, matching ordinary revival; +authoritative play still validates physical custody. A distinct potential leak +in Ambassador acquisition availability remains open. +[Scope](DUKE_VIDAL_RULES.md#concealed-duke-ghola-guidance--29-october-2026). + ## Caladan occupier's auction knowledge — 29 October 2026 The existing Truthtrance auction fact recognizes a successfully inspected diff --git a/game/engine.ts b/game/engine.ts index 2971bc92..8c29e5a1 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -48669,8 +48669,8 @@ export function viewGame(state: Game, id: string, now?: number) { } } // A concealed capture may change the real death count without revealing it - // to the native owner. Derive guidance from the same projected discs that - // the seat can inspect; action validation still uses authoritative custody. + // to the native owner. Derive ordinary and Ghola revival guidance from the + // same projected discs the seat can inspect; actions still use actual custody. const visibleRevivalGame = me.faction === 'ecaz' && g.dukeVidal ? { ...g, dukeVidal: { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) } } @@ -49704,7 +49704,7 @@ export function viewGame(state: Game, id: string, now?: number) { ), freeBlocked: g.revivalRules?.freeBlocked?.includes(id) ?? false, }, - ghola: gholaOptions(g, me), + ghola: gholaOptions(visibleRevivalGame, me), ...(me.faction === 'beneGesserit' && g.advisorMatchingDenied?.length ? {advisorMatchingDenied:g.advisorMatchingDenied} : {}), aid: { diff --git a/tests/ecaz-duke-ghola-privacy.test.ts b/tests/ecaz-duke-ghola-privacy.test.ts new file mode 100644 index 00000000..aad8e47d --- /dev/null +++ b/tests/ecaz-duke-ghola-privacy.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, newPlayer, viewGame } from '../game/engine'; +import { acquireDuke, captureDuke, createDukeVidal, DUKE_VIDAL_ID, executeCapturedDuke } from '../game/duke-vidal'; +import { newRevivalRules } from '../game/revival'; +import { createNexusCards } from '../game/nexus-cards'; +import { baseDeck } from '../game/cards'; +import { ecazDukeRevivalBlock } from '../game/ecaz-duke-revival'; + +/** Staged public-equivalent custody in a supported Advanced Nexus roster. */ +function fixture() { + const game = createGame('DUKEGHOLAPRIVACY', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']); + game.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild')); + Object.assign(game, { status:'playing', phase:0, turn:1, order:['ec','h','g'], + revivalRules:newRevivalRules(), nexusCards:{cards:createNexusCards(game.players, () => 0.5),phase:null} }); + game.deck = baseDeck(); + const ghola = game.deck.findIndex(card => card.effect === 'ghola'); + assert.ok(ghola >= 0); + game.players[0].hand.push(game.deck.splice(ghola, 1)[0]); + const prior = acquireDuke(createDukeVidal(), 'g', 1, 'ecaz'); + game.dukeVidal = captureDuke(prior, 'h', 'g', 1, 'arrakeen'); + return game; +} + +void test('Ecaz Ghola options do not disclose a secretly executed foreign Duke captive', () => { + const alive = fixture(), executed = structuredClone(alive); + executed.dukeVidal = executeCapturedDuke(executed.dukeVidal!, 'h', executed.turn); + assert.ok(ecazDukeRevivalBlock(alive, 'ec'), 'Actual living captive is unavailable'); + assert.equal(ecazDukeRevivalBlock(executed, 'ec'), null, 'Secret execution changes authoritative eligibility'); + const before = viewGame(alive, 'ec'), after = viewGame(executed, 'ec'); + assert.deepEqual(before.dukeVidal, after.dukeVidal, 'Duke custody remains publicly equivalent'); + assert.deepEqual(before.ghola, after.ghola, 'Ghola options must not reveal the hidden execution'); + assert.equal(after.ghola.leaders.some(leader => leader.id === DUKE_VIDAL_ID), false); +}); + +void test('Ecaz is offered its publicly dead Duke for Ghola', () => { + const game = fixture(); + game.dukeVidal = createDukeVidal(); + game.dukeVidal.leader.dead = true; + game.dukeVidal.leader.deaths = 1; + assert.equal(viewGame(game, 'ec').ghola.leaders.some(leader => leader.id === DUKE_VIDAL_ID), true); +}); diff --git a/tests/fixture-ecaz-revival-cohort.ts b/tests/fixture-ecaz-revival-cohort.ts index 286727aa..7a0a19e0 100644 --- a/tests/fixture-ecaz-revival-cohort.ts +++ b/tests/fixture-ecaz-revival-cohort.ts @@ -9,6 +9,9 @@ import { } from './fixture-nexus-duke-capture'; type Transition = { before: Game; after: Game; leader: string }; +/** Do not stop at another faction's ordered Advanced Revival opportunity. */ +const ownerRevival = (game: Game, owner: string) => game.phase === 4 && nexusDukeClean(game) && + (!game.revivalOrder || game.revivalOrder.remaining[0] === owner); export type NativeEcazCohortFixture = { game: Game; owner: string; captor: string; plan: NexusDukePlan; actions: NexusDukeStep[]; deaths: Transition[]; funding: { before: Game; after: Game; amount: number }; @@ -73,7 +76,7 @@ export function createNativeEcazCohortFixture(): NativeEcazCohortFixture { game = stepNexusDuke(game, plan, { actor: captor, action: { type: 'bribe', target: owner, amount } }, actions); const funding = { before: fundingBefore, after: structuredClone(game), amount }; - game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions); + game = advanceNexusDuke(game, g => ownerRevival(g, owner), plan, actions); const firstBefore = structuredClone(game); const firstLeader = native[0]; assert.ok(viewGame(game, owner).revival.leaders.some(l => l.id === firstLeader)); @@ -81,7 +84,7 @@ export function createNativeEcazCohortFixture(): NativeEcazCohortFixture { action: { type: 'reviveLeader', leader: firstLeader } }, actions); const first = { before: firstBefore, after: structuredClone(game), leader: firstLeader }; const repeat = kill(firstLeader); - game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions); + game = advanceNexusDuke(game, g => ownerRevival(g, owner), plan, actions); plan.ship = {}; assert.equal(nexusDukePlayer(game, owner).leaders.find(l => l.id === firstLeader)!.deaths, 2); assertNexusDukeCustody(game); @@ -119,7 +122,7 @@ export function createNativeEcazSecondCohortFixture(): NativeEcazSecondCohortFix const paid: Transition[] = [], repeats: Transition[] = []; let ghola!: Transition; for (let i = 0; i < 3; i++) { - game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions); + game = advanceNexusDuke(game, g => ownerRevival(g, owner), plan, actions); const before = structuredClone(game); game = stepNexusDuke(game, plan, { actor: owner, action: { type: 'reviveLeader', leader: native[i] } }, actions); @@ -138,7 +141,7 @@ export function createNativeEcazSecondCohortFixture(): NativeEcazSecondCohortFix } const last = poisonDeath(game, death, native[3]); repeats.push(last); - game = advanceNexusDuke(last.after, g => g.phase === 4 && nexusDukeClean(g), plan, actions); + game = advanceNexusDuke(last.after, g => ownerRevival(g, owner), plan, actions); plan.ship = {}; assert.equal(nexusDukePlayer(game, owner).leaders.find(l => l.id === native[4])!.dead, false); assert.equal(game.dukeVidal!.leader.dead, true);