Restore safe Duke grants in early legacy games
Verify and publish checkpoint container / container (push) Successful in 10m31s
Verify and publish checkpoint container / container (push) Successful in 10m31s
This commit is contained in:
1 parent
59cb336c80
commit
22b463a395
10 files changed
+263
-11
No files matched your search
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { acquireDuke } from '../game/duke-vidal';
|
||||
import { normalizeAutomaticGame } from '../game/engine';
|
||||
import { actHomeworldEcazDuke, createHomeworldEcazDukeFixture } from './fixture-homeworld-ecaz-duke';
|
||||
|
||||
const displacement = 'Occupied Ecaz displaced Duke Vidal’s ordinary faction grant';
|
||||
|
||||
for (const legacy of [false, true]) void test(
|
||||
`actual occupied Ecaz displacement clears ${legacy ? 'legacy' : 'uncertain'} Duke capture history`, () => {
|
||||
const f = createHomeworldEcazDukeFixture({ advanced: true, allied: true });
|
||||
// The public occupation and its recipient choice are earned by the native
|
||||
// fixture. Stage only the prior ordinary grant and uncertainty receipt at
|
||||
// the real battle boundary, then replay the fixture's actual battle plans.
|
||||
let game = structuredClone(f.worldResolution.before);
|
||||
game.dukeVidal = acquireDuke(game.dukeVidal!, f.ecaz, game.turn, 'ecaz');
|
||||
if (legacy) delete game.dukeCaptureUncertain;
|
||||
else game.dukeCaptureUncertain = true;
|
||||
const leader = structuredClone(game.dukeVidal.leader);
|
||||
for (const step of f.worldResolution.steps) game = actHomeworldEcazDuke(game, step.actor, step.action);
|
||||
assert.ok(game.log.some(entry => entry.text.includes(displacement)));
|
||||
assert.equal(game.dukeCaptureUncertain, false);
|
||||
assert.equal(game.dukeVidal?.controller, null);
|
||||
assert.equal(game.dukeVidal?.source, null);
|
||||
assert.deepEqual(game.dukeVidal?.leader, leader);
|
||||
assert.ok(f.ally);
|
||||
assert.equal(game.homeworldEcazDukeGrants?.length ?? 0, 0, 'the occupier still has a recipient choice');
|
||||
});
|
||||
|
||||
for (const legacy of [false, true]) void test(
|
||||
`without an ordinary Duke displacement ${legacy ? 'legacy' : 'uncertain'} capture history remains`, () => {
|
||||
const f = createHomeworldEcazDukeFixture({ advanced: true, allied: true });
|
||||
// This earned occupied position has no ordinary holder to displace and has
|
||||
// two recipients, so merely checking the opportunity must not clear it.
|
||||
const source = structuredClone(f.game);
|
||||
if (legacy) delete source.dukeCaptureUncertain;
|
||||
else source.dukeCaptureUncertain = true;
|
||||
const beforeLogs = source.log.filter(entry => entry.text.includes(displacement)).length;
|
||||
const done = normalizeAutomaticGame(source);
|
||||
assert.equal(done.dukeCaptureUncertain, legacy ? undefined : true);
|
||||
assert.equal(done.log.filter(entry => entry.text.includes(displacement)).length, beforeLogs);
|
||||
assert.deepEqual(done.dukeVidal, source.dukeVidal);
|
||||
});
|
||||
@@ -116,6 +116,11 @@ void test('a real Duke-possible capture marks uncertainty for either concealed s
|
||||
|
||||
void test('legacy games without the capture marker uniformly guard equivalent Duke custody', () => {
|
||||
const {source, free, captured} = paired();
|
||||
// The paired fixture stages an alternate prior capture. That history cannot
|
||||
// occur before the first Battle, so place both public-equivalent outcomes
|
||||
// at a later turn before removing the legacy marker.
|
||||
free.turn = 2;
|
||||
captured.turn = 2;
|
||||
delete free.dukeCaptureUncertain;
|
||||
delete captured.dukeCaptureUncertain;
|
||||
const freeEntry = viewGame(free, source.owner).ambassadorEntry;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { acquireDuke, captureDuke } from '../game/duke-vidal';
|
||||
import { actDukeAmbassador, advanceDukeAmbassador, dukeAmbassadorClean } from './fixture-duke-ambassador';
|
||||
|
||||
/** Actual Advanced setup and Harkonnen arrival, with only the unopened Spice
|
||||
* cards and the existing fixture's scalar lotteries controlled. */
|
||||
function initialArrival(): Game {
|
||||
let game = createGame('LEGACYINITIALDUKE', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
const act = (actor: string, action: Action) => { game = actDukeAmbassador(game, actor, action); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceDukeAmbassador(game, until); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
act(game.host, { type: 'start', advancedPreview: true });
|
||||
reach(state => state.status === 'playing');
|
||||
const token = game.ecazAmbassadors!.tokens.find(item => item.effect === 'ecaz');
|
||||
assert.ok(token && token.zone === 'supply');
|
||||
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));
|
||||
act('harkonnen', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.pendingAmbassador!.token, token.id);
|
||||
assert.equal(game.dukeCaptureUncertain, false);
|
||||
delete game.dukeCaptureUncertain; // The only change simulates an older saved game.
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('an unstamped first-turn Advanced arrival allows the Duke and stamps only an accepted action', () => {
|
||||
const game = initialArrival();
|
||||
const original = structuredClone(game);
|
||||
assert.equal(viewGame(game, 'ecaz').ambassadorEntry?.dukeAcquisition?.blocked, null);
|
||||
assert.deepEqual(game, original, 'Viewing a legacy save does not mutate it');
|
||||
assert.throws(() => applyAction(game, 'guild', { type: 'decision', event: game.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: 'ecaz', choice: 'duke' }));
|
||||
assert.deepEqual(game, original, 'A rejected action leaves both state and marker unchanged');
|
||||
const after = applyAction(game, 'ecaz', { type: 'decision', event: game.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: 'ecaz', choice: 'duke' });
|
||||
assert.equal(after.dukeCaptureUncertain, false);
|
||||
assert.deepEqual([after.dukeVidal?.controller, after.dukeVidal?.source], ['ecaz', 'ecaz']);
|
||||
assert.deepEqual(game, original, 'Applying an action leaves the input snapshot intact');
|
||||
});
|
||||
|
||||
void test('automatic continuation records first-turn public proof without mutating the source', () => {
|
||||
const game = initialArrival();
|
||||
const original = structuredClone(game);
|
||||
const after = normalizeAutomaticGame(game);
|
||||
assert.equal(after.dukeCaptureUncertain, false);
|
||||
assert.deepEqual(game, original);
|
||||
});
|
||||
|
||||
void test('unstamped later games uniformly guard publicly equivalent Duke custody', () => {
|
||||
const first = initialArrival();
|
||||
for (const boundary of [{ turn: 1, phase: 6 }, { turn: 2, phase: 5 }]) {
|
||||
const free = structuredClone(first);
|
||||
Object.assign(free, boundary);
|
||||
const prior = acquireDuke(free.dukeVidal!, 'guild', free.turn, 'ecaz');
|
||||
free.dukeVidal = prior;
|
||||
const captured = structuredClone(free);
|
||||
captured.dukeVidal = captureDuke(prior, 'harkonnen', 'guild', free.turn, 'carthag');
|
||||
const freeEntry = viewGame(free, 'ecaz').ambassadorEntry;
|
||||
const capturedEntry = viewGame(captured, 'ecaz').ambassadorEntry;
|
||||
assert.ok(freeEntry && capturedEntry);
|
||||
assert.deepEqual(capturedEntry, freeEntry);
|
||||
assert.ok(freeEntry.dukeAcquisition?.blocked, 'Unknown past capture stays uniformly guarded');
|
||||
for (const game of [free, captured]) {
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'ecaz', { type: 'decision', event: game.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: 'ecaz', choice: 'duke' }));
|
||||
assert.deepEqual(game, original);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DUKE_CAPTURE_UNCERTAIN_BLOCK,
|
||||
dukeCaptureUncertaintyBlock,
|
||||
legacyDukeCaptureHistoryClear,
|
||||
type DukeCapturePolicyContext,
|
||||
} from '../game/ecaz-duke-capture-policy';
|
||||
|
||||
function earlyLegacy(): DukeCapturePolicyContext {
|
||||
return {
|
||||
advanced: true,
|
||||
players: [{ faction: 'ecaz' }, { faction: 'harkonnen' }],
|
||||
status: 'playing', turn: 1, phase: 0,
|
||||
battle: null, lastBattleContext: null, pendingCapture: null,
|
||||
response: null, decision: null,
|
||||
};
|
||||
}
|
||||
|
||||
void test('public first-turn phases before battle prove an unstamped legacy game safe', () => {
|
||||
for (let phase = 0; phase <= 5; phase++) {
|
||||
const game = { ...earlyLegacy(), phase };
|
||||
Object.defineProperty(game, 'dukeVidal', { get() { throw new Error('private custody read'); } });
|
||||
Object.defineProperty(game.players[1], 'capturedLeaders', { get() { throw new Error('private capture read'); } });
|
||||
assert.equal(legacyDukeCaptureHistoryClear(game), true);
|
||||
assert.equal(dukeCaptureUncertaintyBlock(game), null);
|
||||
}
|
||||
const unrelatedContinuation = { ...earlyLegacy(), response: { kind: 'nexus' }, decision: { kind: 'advisor' } };
|
||||
assert.equal(legacyDukeCaptureHistoryClear(unrelatedContinuation), true);
|
||||
});
|
||||
|
||||
void test('late, incomplete, or capture-continuation states cannot prove legacy safety', () => {
|
||||
const variants: Partial<DukeCapturePolicyContext>[] = [
|
||||
{ status: 'lobby' }, { status: undefined }, { turn: 2 }, { turn: undefined },
|
||||
{ phase: -1 }, { phase: 6 }, { phase: 0.5 }, { phase: undefined }, { phase: NaN },
|
||||
{ battle: {} }, { lastBattleContext: {} }, { pendingCapture: {} },
|
||||
{ response: { kind: 'capture' } }, { decision: { kind: 'captureOffer' } },
|
||||
{ decision: { kind: 'capturedLeader' } },
|
||||
];
|
||||
for (const variant of variants) {
|
||||
const game = { ...earlyLegacy(), ...variant };
|
||||
assert.equal(legacyDukeCaptureHistoryClear(game), false, JSON.stringify(variant));
|
||||
assert.equal(dukeCaptureUncertaintyBlock(game), DUKE_CAPTURE_UNCERTAIN_BLOCK, JSON.stringify(variant));
|
||||
}
|
||||
});
|
||||
|
||||
void test('explicit uncertainty is preserved and existing policy profiles are unchanged', () => {
|
||||
const uncertain = { ...earlyLegacy(), dukeCaptureUncertain: true };
|
||||
assert.equal(legacyDukeCaptureHistoryClear(uncertain), false);
|
||||
assert.equal(dukeCaptureUncertaintyBlock(uncertain), DUKE_CAPTURE_UNCERTAIN_BLOCK);
|
||||
const knownSafe = { ...earlyLegacy(), dukeCaptureUncertain: false, turn: 2 };
|
||||
assert.equal(legacyDukeCaptureHistoryClear(knownSafe), false);
|
||||
assert.equal(dukeCaptureUncertaintyBlock(knownSafe), null);
|
||||
assert.equal(dukeCaptureUncertaintyBlock({ ...uncertain, advanced: false }), null);
|
||||
assert.equal(dukeCaptureUncertaintyBlock({ ...uncertain, players: [{ faction: 'ecaz' }] }), null);
|
||||
});
|
||||
Reference in new issue
Block a user