Resolve Duke uncertainty on public events
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
Reference in new issue
Block a user