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

@@ -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]) {