Guard Duke grants using public capture uncertainty
Verify and publish checkpoint container / container (push) Successful in 11m21s

This commit is contained in:
Quintine committed 2026-10-29 19:22:46 +11:00
1 parent 5f4eeee4f1
commit 57e7611468
15 files changed
+500 -19

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { nextDukeCaptureUncertainty as next, type DukeCapturePublicSource } from '../game/duke-capture-uncertainty';
const known: DukeCapturePublicSource = {
publicController:'guild', loser:'guild', revealedLeader:'other-leader', publicPlanKnown:true,
};
void test('publicly possible held or just-used Duke makes the capture uncertain', () => {
assert.equal(next(false, known), true);
assert.equal(next(false, {...known, publicController:null, revealedLeader:DUKE_VIDAL_ID}), true);
assert.equal(next(false, {...known, publicController:'ecaz', revealedLeader:'other-leader'}), false);
assert.equal(next(false, {...known, publicController:null, revealedLeader:null}), false);
});
void test('uncertainty persists and unstamped historical games are not rewritten', () => {
assert.equal(next(true, {...known, publicController:null}), true);
assert.equal(next(undefined, known), undefined);
assert.equal(next(undefined, {...known, publicController:undefined}), undefined);
});
void test('incomplete or invalid public sources cannot prove Duke was excluded', () => {
for (const source of [
{...known, publicController:undefined}, {...known, loser:undefined},
{...known, loser:''}, {...known, revealedLeader:undefined},
{...known, revealedLeader:''}, {...known, publicPlanKnown:false},
]) assert.equal(next(false, source), true);
});
void test('publicly equivalent captures stay equivalent regardless of actual selection', () => {
const dukeSelected = {...known, actualSelected:DUKE_VIDAL_ID, concealed:true};
const otherSelected = {...known, actualSelected:'other-leader', concealed:false};
assert.equal(next(false, dukeSelected), next(false, otherSelected));
assert.equal(next(false, dukeSelected), true);
});
+20 -9
View File
@@ -281,15 +281,26 @@ void test('Advanced new-ally Duke grant transfers available shared custody while
assert.deepEqual(done.dukeVidal!.leader, duke!.leader);
}
}
const g = fixture(true);
g.players[2] = newPlayer('a', 'Harkonnen', 'harkonnen');
const entered = enter(g);
assert.ok(viewGame(entered, 'ec').ambassadorEntry!.dukeAcquisition!.blocked);
assert.equal(
viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked,
null,
);
assert.equal(seat(answer(propose(entered), true), 'ec').ally, 'in');
for (const uncertainty of [false, true, undefined] as const) {
const g = fixture(true);
g.players[2] = newPlayer('a', 'Harkonnen', 'harkonnen');
if (uncertainty === undefined) delete g.dukeCaptureUncertain;
else g.dukeCaptureUncertain = uncertainty;
const duke = json(g).dukeVidal;
const entered = enter(g);
const entry = viewGame(entered, 'ec').ambassadorEntry!;
if (uncertainty === false) assert.equal(entry.dukeAcquisition!.blocked, null);
else assert.match(entry.dukeAcquisition!.blocked!, /Harkonnen capture history may be unresolved/);
assert.equal(entry.allianceOffer!.blocked, null);
const done = answer(propose(entered), true);
assert.equal(seat(done, 'ec').ally, 'in');
assert.equal(seat(done, 'in').ally, 'ec');
if (uncertainty === false) {
assert.equal(done.dukeVidal!.controller, 'in');
assert.equal(done.dukeVidal!.source, 'ally');
assert.deepEqual(done.dukeVidal!.leader, duke!.leader);
} else assert.deepEqual(done.dukeVidal, duke, 'An uncertain loan leaves the physical Duke unchanged.');
}
});
void test('changing BG private prediction and unrelated private hands does not change the public alliance offer', () => {
+8 -2
View File
@@ -192,7 +192,7 @@ void test('pure source/canonical identity/custody checks reject safely, with one
EcazDukeAcquisitionError,
);
});
void test('redundant same-owner and Advanced Harkonnen cases keep public reasons independent of private custody', () => {
void test('redundant same-owner and uncertain Advanced Harkonnen cases keep public reasons independent of private custody', () => {
const own = fixture();
own.dukeVidal = acquireDuke(own.dukeVidal!, 'ec', 1, 'ecaz');
const reason = ecazDukeAcquisitionBlock(own, 'ec');
@@ -200,10 +200,15 @@ void test('redundant same-owner and Advanced Harkonnen cases keep public reasons
own.dukeVidal.leader.capturedBy = 'in';
assert.equal(ecazDukeAcquisitionBlock(own, 'ec'), reason);
const hark = fixture('harkonnen', true);
assert.equal(hark.dukeCaptureUncertain, false);
assert.equal(ecazDukeAcquisitionBlock(hark, 'ec'), null);
hark.dukeCaptureUncertain = true;
const blocked = ecazDukeAcquisitionBlock(hark, 'ec');
assert.match(blocked!, /Advanced Harkonnen/);
assert.match(blocked!, /Harkonnen capture history may be unresolved/);
hark.dukeVidal!.leader.capturedBy = 'in';
assert.equal(ecazDukeAcquisitionBlock(hark, 'ec'), blocked);
delete hark.dukeCaptureUncertain;
assert.equal(ecazDukeAcquisitionBlock(hark, 'ec'), blocked);
const basic = fixture('harkonnen', false);
assert.equal(ecazDukeAcquisitionBlock(basic, 'ec'), null);
});
@@ -316,6 +321,7 @@ void test('explicit Duke choice/owner/event and eligibility are checked before t
(b: Game) => {
b.advanced = true;
p(b, 'in').faction = 'harkonnen';
b.dukeCaptureUncertain = true;
},
]) {
const bad = reload(g);
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { acquireDuke, captureDuke, DUKE_VIDAL_ID } from '../game/duke-vidal';
import { createNexusDukeCaptureFixture } from './fixture-nexus-duke-capture';
/** The Ambassador placement and offer are reached by real actions. We stage
* two alternate custody outcomes for the same physical disc and parent event:
* a living third-party Duke and its legal concealed Harkonnen capture. */
function paired() {
const source = createNexusDukeCaptureFixture();
const free = structuredClone(source.arrival.before);
const priorController = source.others.find(id => id !== source.captor && id !== source.entrant);
assert.ok(priorController, 'A third party owns the staged prior Duke');
const prior = acquireDuke(free.dukeVidal!, priorController, free.turn, 'ecaz');
free.dukeVidal = prior;
const captured: Game = structuredClone(free);
captured.dukeVidal = captureDuke(prior, source.captor, priorController, free.turn, 'carthag');
// Public history records a possible hidden Duke capture, regardless of
// whether this physical disc was the captive actually selected.
Object.assign(free, { dukeCaptureUncertain: true });
Object.assign(captured, { dukeCaptureUncertain: true });
assert.equal(captured.pendingAmbassador?.event, free.pendingAmbassador?.event);
const freeView = viewGame(free, source.owner), capturedView = viewGame(captured, source.owner);
for (const key of Object.keys(freeView) as (keyof typeof freeView)[]) {
if (key !== 'ambassadorEntry') assert.deepEqual(capturedView[key], freeView[key], `Public-equivalent ${key}`);
}
return { source, free, captured };
}
void test('Ecaz Ambassador availability must not reveal a concealed Duke captured from another controller', () => {
const {source, free, captured} = paired();
const freeEntry = viewGame(free, source.owner).ambassadorEntry;
const hiddenEntry = viewGame(captured, source.owner).ambassadorEntry;
assert.ok(freeEntry && hiddenEntry);
const action = {type:'decision', event:free.pendingAmbassador!.event, trigger:true, beneficiary:source.owner, choice:'duke'};
for (const game of [free, captured]) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, source.owner, action));
assert.deepEqual(game, before, 'The unavailable action preserves the physical Ambassador and Duke');
}
assert.deepEqual(hiddenEntry, freeEntry, 'The same public Duke and parent event must offer identical controls');
assert.ok(freeEntry.dukeAcquisition?.blocked, 'The same public uncertainty uniformly blocks the acquisition');
});
void test('an actual fresh Ambassador grant stays available before public capture uncertainty', () => {
const source = createNexusDukeCaptureFixture(), game = structuredClone(source.arrival.before);
assert.equal(viewGame(game, source.owner).ambassadorEntry?.dukeAcquisition?.blocked, null);
const after = applyAction(game, source.owner, source.arrival.step.action);
assert.equal(after.dukeVidal?.controller, source.owner);
assert.equal(after.dukeVidal?.source, 'ecaz');
});
void test('a fresh Advanced Ecaz alliance grants the unambiguous Duke without another choice', () => {
const source = createNexusDukeCaptureFixture(), game = structuredClone(source.arrival.before);
const event = game.pendingAmbassador!.event;
const offered = applyAction(game, source.owner, {type:'decision', event, trigger:true,
beneficiary:source.owner, choice:'alliance'});
assert.equal(offered.pendingAmbassador?.stage, 'allianceReply');
const accepted = applyAction(offered, source.entrant, {type:'decision', event, accept:true});
assert.equal(accepted.players.find(player => player.id === source.owner)?.ally, source.entrant);
assert.equal(accepted.dukeVidal?.controller, source.entrant);
assert.equal(accepted.dukeVidal?.source, 'ally');
assert.notEqual(accepted.pendingAmbassador?.stage, 'loan');
});
void test('an Advanced alliance does not reveal uncertain Duke custody through its automatic grant', () => {
const {source, free, captured} = paired();
const results: Game[] = [];
for (const game of [free, captured]) {
const originalDuke = structuredClone(game.dukeVidal);
const event = game.pendingAmbassador!.event;
const offered = applyAction(game, source.owner, {type:'decision', event, trigger:true,
beneficiary:source.owner, choice:'alliance'});
const accepted = applyAction(offered, source.entrant, {type:'decision', event, accept:true});
assert.equal(accepted.players.find(player => player.id === source.owner)?.ally, source.entrant);
assert.deepEqual(accepted.dukeVidal, originalDuke, 'No automatic grant changes either actual custody');
assert.notEqual(accepted.pendingAmbassador?.stage, 'loan');
results.push(accepted);
}
assert.deepEqual(viewGame(results[0], source.owner), viewGame(results[1], source.owner));
assert.deepEqual(viewGame(results[0], source.entrant), viewGame(results[1], source.entrant));
});
void test('a real Duke-possible capture marks uncertainty for either concealed selection', () => {
const source = createNexusDukeCaptureFixture({variant:'used'});
assert.ok(source.offer);
assert.ok(source.candidates.some(leader => leader.id === DUKE_VIDAL_ID));
assert.ok(source.candidates.some(leader => leader.id !== DUKE_VIDAL_ID));
assert.equal(source.offer.before.dukeCaptureUncertain, false);
assert.equal(source.offer.after.decision?.kind, 'capturedLeader');
if (source.offer.after.decision?.kind !== 'capturedLeader') throw Error('Missing capture selection');
assert.equal(source.offer.after.decision.leader, DUKE_VIDAL_ID);
assert.equal(source.offer.after.dukeCaptureUncertain, true);
assert.equal(source.game.dukeCaptureUncertain, true);
// Select a different original candidate solely by controlled shuffle entropy,
// replaying the same real public capture and preserving all physical discs.
const saved = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
try {
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) { array.fill(0); return array; }
return Reflect.apply(native, crypto, [array]);
};
const different = applyAction(structuredClone(source.offer.before), source.offer.step.actor, source.offer.step.action);
assert.equal(different.decision?.kind, 'capturedLeader');
if (different.decision?.kind !== 'capturedLeader') throw Error('Missing capture selection');
assert.notEqual(different.decision.leader, DUKE_VIDAL_ID);
assert.equal(different.dukeCaptureUncertain, true, 'The public marker cannot depend on hidden selection');
} finally {
if (saved) Object.defineProperty(crypto, 'getRandomValues', saved);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
});
void test('legacy games without the capture marker uniformly guard equivalent Duke custody', () => {
const {source, free, captured} = paired();
delete free.dukeCaptureUncertain;
delete captured.dukeCaptureUncertain;
const freeEntry = viewGame(free, source.owner).ambassadorEntry;
const hiddenEntry = viewGame(captured, source.owner).ambassadorEntry;
assert.ok(freeEntry && hiddenEntry);
assert.deepEqual(hiddenEntry, freeEntry);
assert.ok(freeEntry.dukeAcquisition?.blocked, 'Unknown legacy capture history must not be assumed safe');
const action = {type:'decision', event:free.pendingAmbassador!.event, trigger:true, beneficiary:source.owner, choice:'duke'};
for (const game of [free, captured]) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, source.owner, action));
assert.deepEqual(game, before);
}
});
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, type Game } from '../game/engine';
import { acquireDuke, captureDuke, createDukeVidal } from '../game/duke-vidal';
import { ecazDukeAcquisitionBlock, quoteEcazDukeAcquisition } from '../game/ecaz-duke-acquisition';
import { DUKE_CAPTURE_UNCERTAIN_BLOCK, dukeCaptureUncertaintyBlock, plainAdvancedEcazDukeProfile } from '../game/ecaz-duke-capture-policy';
function table(): Game {
const game = createGame('DUKEPOLICY', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
game.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'));
game.status = 'playing'; game.turn = 2; game.dukeVidal = createDukeVidal();
game.dukeCaptureUncertain = false;
return game;
}
void test('a known-safe plain Advanced table can acquire the original living Duke', () => {
const game = table(), before = structuredClone(game);
assert.equal(plainAdvancedEcazDukeProfile(game), true);
assert.equal(dukeCaptureUncertaintyBlock(game), null);
const next = quoteEcazDukeAcquisition(game, 'e');
assert.equal(next.controller, 'e'); assert.equal(next.leader.id, game.dukeVidal!.leader.id);
assert.deepEqual(game, before, 'A quote does not change custody.');
const ecaz = game.players.find(player => player.id === 'e')!;
const guild = game.players.find(player => player.id === 'g')!;
ecaz.ally = 'g'; guild.ally = 'e';
assert.equal(quoteEcazDukeAcquisition(game, 'e', 'g').controller, 'g');
});
void test('uncertain and legacy Advanced tables block all hidden custody identically', () => {
const free = table();
free.dukeVidal = acquireDuke(free.dukeVidal!, 'g', free.turn, 'ecaz');
const captured = structuredClone(free);
captured.dukeVidal = captureDuke(captured.dukeVidal!, 'h', 'g', captured.turn, 'arrakeen');
for (const uncertain of [true, undefined] as const) {
for (const game of [free, captured]) {
if (uncertain === undefined) delete game.dukeCaptureUncertain;
else game.dukeCaptureUncertain = uncertain;
const before = structuredClone(game);
assert.equal(ecazDukeAcquisitionBlock(game, 'e'), DUKE_CAPTURE_UNCERTAIN_BLOCK);
assert.throws(() => quoteEcazDukeAcquisition(game, 'e'));
assert.deepEqual(game, before);
}
}
});
void test('uncertainty does not change Basic or tables without Harkonnen', () => {
const basic = table(); basic.advanced = false; delete basic.dukeCaptureUncertain;
assert.equal(dukeCaptureUncertaintyBlock(basic), null);
assert.equal(quoteEcazDukeAcquisition(basic, 'e').controller, 'e');
const noHarkonnen = table(); noHarkonnen.players = noHarkonnen.players.filter(player => player.id !== 'h');
delete noHarkonnen.dukeCaptureUncertain;
assert.equal(dukeCaptureUncertaintyBlock(noHarkonnen), null);
assert.equal(quoteEcazDukeAcquisition(noHarkonnen, 'e').controller, 'e');
});
void test('the plain profile keeps optional modules separate and validates the selected roster', () => {
const game = table();
assert.equal(plainAdvancedEcazDukeProfile(game), true);
const mixed = structuredClone(game); mixed.expansions.push('ix');
mixed.players.push(newPlayer('i', 'Ixians', 'ixians'));
assert.equal(plainAdvancedEcazDukeProfile(mixed), true);
mixed.sandtrout = true;
assert.equal(plainAdvancedEcazDukeProfile(mixed), true, 'Active native Sandtrout is not an optional selection.');
mixed.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
assert.equal(plainAdvancedEcazDukeProfile(mixed), false);
assert.match(ecazDukeAcquisitionBlock(mixed, 'e')!, /Advanced Harkonnen/);
delete mixed.leaderSkills; mixed.expansions = ['ecaz'];
assert.equal(plainAdvancedEcazDukeProfile(mixed), false, 'An unselected expansion cannot seat Ixians.');
});
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { actDukeAmbassador, advanceDukeAmbassador, dukeAmbassadorClean } from './fixture-duke-ambassador';
/** Genuine Advanced setup, placement and shipment. Only unopened Spice cards
* and the original scalar lotteries are controlled to avoid a worm detour. */
function nativeArrival() {
let game = createGame('PLAINECAZHARKONNENDUKE', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
const actions: { actor:string; action:Action }[] = [];
const act = (actor:string, action:Action) => { game = actDukeAmbassador(game, actor, action, actions); };
const reach = (until:(state:Game)=>boolean) => { game = advanceDukeAmbassador(game, until, actions); };
for (const player of game.players) act(player.id, {type:'ready'});
act(game.host, {type:'start', advancedPreview:true});
reach(state => state.status === 'playing');
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, ['ecaz']);
assert.equal(game.dukeCaptureUncertain, false);
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.discoveryEnabled);
const token = game.ecazAmbassadors!.tokens.find(item => item.effect === 'ecaz');
assert.ok(token && token.zone === 'supply');
const originalDuke = structuredClone(game.dukeVidal!.leader);
for (const position of [0,1]) {
const index = game.spiceDeck.findIndex((card,i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(index >= position);
[game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]];
}
reach(state => state.phase === 4 && !state.response && state.decision?.kind === 'ecazPlacement');
act('ecaz', {type:'decision', token:token.id, territory:'arrakeen'});
reach(state => state.phase === 4 && !state.response && state.decision?.kind === 'ecazPlacement');
act('ecaz', {type:'decision', decline:true});
reach(state => state.phase === 5 && state.active === 'harkonnen' && dukeAmbassadorClean(state));
const beforeSpice = game.players.find(player => player.id === 'harkonnen')!.spice;
act('harkonnen', {type:'ship', amount:1, territory:'arrakeen', sector:10});
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
assert.equal(game.pendingAmbassador!.token, token.id);
return { game, token, originalDuke, beforeSpice, actions };
}
void test('plain Advanced Ecaz may acquire the original Duke on a real Harkonnen arrival before any capture', () => {
const source = nativeArrival();
let game = source.game;
const {token, originalDuke, beforeSpice, actions} = source;
const act = (actor:string, action:Action) => { game = actDukeAmbassador(game, actor, action, actions); };
const reach = (until:(state:Game)=>boolean) => { game = advanceDukeAmbassador(game, until, actions); };
const offer = viewGame(game, 'ecaz').ambassadorEntry;
assert.ok(offer);
assert.equal(offer.dukeAcquisition?.blocked, null);
const event = game.pendingAmbassador!.event;
act('ecaz', {type:'decision', event, trigger:true, beneficiary:'ecaz', choice:'duke'});
reach(state => dukeAmbassadorClean(state) && !state.pendingAmbassador);
assert.deepEqual(game.dukeVidal!.leader, originalDuke);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source], ['ecaz','ecaz']);
assert.equal(game.ecazAmbassadors!.tokens.find(item => item.id === token.id)!.zone, 'supply');
assert.equal(game.players.find(player => player.id === 'harkonnen')!.forces['arrakeen:10'], 1);
assert.ok(game.players.find(player => player.id === 'harkonnen')!.spice < beforeSpice);
assert.equal(game.active, 'harkonnen', 'The original shipment and movement turn resumes');
act('harkonnen', {type:'endMovement'});
assert.notEqual(game.active, 'harkonnen');
assert.ok(game.players.every(player => player.leaders.every(leader => leader.id !== DUKE_VIDAL_ID)));
});
void test('plain Advanced Ecaz alliance automatically lends the original Duke on Harkonnen acceptance', () => {
const source = nativeArrival();
let game = source.game;
const {token, originalDuke, actions} = source;
const act = (actor:string, action:Action) => { game = actDukeAmbassador(game, actor, action, actions); };
const reach = (until:(state:Game)=>boolean) => { game = advanceDukeAmbassador(game, until, actions); };
const event = game.pendingAmbassador!.event;
act('ecaz', {type:'decision', event, trigger:true, beneficiary:'ecaz', choice:'alliance'});
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
act('harkonnen', {type:'decision', event, accept:true});
reach(state => dukeAmbassadorClean(state) && !state.pendingAmbassador);
assert.equal(game.players.find(player => player.id === 'ecaz')!.ally, 'harkonnen');
assert.equal(game.players.find(player => player.id === 'harkonnen')!.ally, 'ecaz');
assert.deepEqual(game.dukeVidal!.leader, originalDuke);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source], ['harkonnen','ally']);
assert.equal(game.ecazAmbassadors!.tokens.find(item => item.id === token.id)!.zone, 'supply');
assert.equal(game.players.find(player => player.id === 'harkonnen')!.forces['arrakeen:10'], 1);
assert.equal(game.active, 'harkonnen', 'The original movement turn resumes without a loan acknowledgement');
assert.ok(game.players.every(player => player.leaders.every(leader => leader.id !== DUKE_VIDAL_ID)));
});