Files
Dune/tests/homeworld-ecaz-duke-captive.test.ts
T

180 lines
9.6 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { newPlayer } from '../game/engine';
import {
acquireDuke, captureDuke, createDukeVidal, dukeController, executeCapturedDuke,
expireDuke, releaseCapturedDuke, retireHomeworldDukeTenure, returnedDukeCapture,
} from '../game/duke-vidal';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { createHomeworldOccupationHistory, observeHomeworldOccupation } from '../game/homeworld-occupation-history';
import {
createHomeworldEcazDukeReceipt, quoteHomeworldEcazDuke,
quoteHomeworldEcazDukeLifecycle as lifecycle, releaseCapturedHomeworldDuke,
type HomeworldEcazDukeContext as Context,
} from '../game/homeworld-ecaz-duke';
const player = (g: Context, id: string) => {
const p = g.players.find(p => p.id === id); assert.ok(p); return p;
};
function observe(g: Context, event: string) {
g.homeworldOccupationHistory = observeHomeworldOccupation(g.homeworldOccupationHistory!,
homeworldContext(g), g.homeworlds!.custody!, g.turn, 'change', event);
}
/** Controlled helper positions, not native battle/capture chronology. Original
* typed pools and canonical observation create the occupied-source receipt;
* real Duke capture/release/execution helpers then exercise its lifecycle. */
function position(allied = false): Context {
const players = (['ecaz', 'guild', 'atreides', 'harkonnen'] as const)
.map(faction => newPlayer(faction, faction, faction));
const context = homeworldContext({ advanced: true, players });
const custody = createHomeworldCustody(context);
const g: Context = { status: 'playing', advanced: true, turn: 1, players,
homeworlds: { custody, historyVersion: 1 }, homeworldOccupationPreview: true,
homeworldOccupationHistory: createHomeworldOccupationHistory(context, custody, 1, 'controlled-native-pools'),
dukeVidal: createDukeVidal() };
player(g, 'ecaz').tanks += player(g, 'ecaz').reserves;
player(g, 'ecaz').reserves = 0;
player(g, 'guild').reserves--;
custody.visitors['homeworld:ecaz'] = { guild: { normal: 1, elite: 0 } };
observe(g, 'controlled-guild-occupation');
if (allied) {
player(g, 'guild').ally = 'atreides';
player(g, 'atreides').ally = 'guild';
}
const offer = quoteHomeworldEcazDuke(g);
assert.ok(offer.event && offer.owner); assert.equal(offer.blocked, null);
const recipient = allied ? 'atreides' : 'guild';
g.homeworldEcazDukeGrants = [createHomeworldEcazDukeReceipt(g, offer.owner, offer.event, recipient)];
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, recipient, 1, 'homeworldEcaz'),
'harkonnen', recipient, 1, 'sietch_tabr');
return g;
}
function depart(g: Context) {
const group = g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild;
player(g, 'guild').reserves += group.normal + group.elite;
delete g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild;
observe(g, 'controlled-guild-departure');
}
const noTenure = { controller: null, acquiredTurn: null, source: null };
void test('early captive return restores a still-qualified occupied Ecaz tenure without a second grant', () => {
const g = position(), before = structuredClone(g);
assert.equal(lifecycle(g).release, false);
assert.match(lifecycle(g).blocked!, /captured, concealed or Ghola/);
const released = releaseCapturedHomeworldDuke(g, 'harkonnen');
assert.equal(released.controller, 'guild'); assert.equal(released.source, 'homeworldEcaz');
assert.equal(released.acquiredTurn, 1); assert.equal(released.leader.capturedBy, undefined);
assert.equal(released.captureVisibility, undefined); assert.equal(released.leader.concealed, undefined);
assert.deepEqual(g, before);
g.dukeVidal = released;
assert.equal(quoteHomeworldEcazDuke(g).claimed?.recipient, 'guild');
assert.match(quoteHomeworldEcazDuke(g).blocked!, /already chose/);
});
void test('public turn-end retirement preserves the captive disc and later release cannot revive its lease', () => {
const g = position(), held = g.dukeVidal!;
held.leader.deaths = 2; held.leader.concealed!.deaths = 2;
held.leader.usedAt = 'carthag';
const before = structuredClone(held), retired = expireDuke(held, 1);
assert.deepEqual(held, before);
assert.deepEqual(retired.leader, held.leader);
assert.equal(dukeController(retired), 'harkonnen');
assert.equal(retired.controller, null); assert.equal(retired.source, null);
assert.equal(retired.acquiredTurn, null); assert.deepEqual(retired.captureVisibility, noTenure);
assert.equal(retired.leader.concealed?.controller, 'guild');
g.dukeVidal = retired;
const released = releaseCapturedHomeworldDuke(g, 'harkonnen', 2);
assert.equal(dukeController(released), null); assert.equal(released.source, null);
assert.equal(released.leader.usedAt, 'carthag'); assert.equal(released.leader.deaths, 2);
assert.equal(released.leader.concealed, undefined);
assert.throws(() => releaseCapturedHomeworldDuke(g, 'atreides', 2));
});
void test('departure, replacement qualification and lost recipient are rechecked during actual captive return', () => {
for (const reason of ['occupationEnded', 'occupationChanged', 'recipientChanged'] as const) {
const g = position(reason === 'recipientChanged');
if (reason === 'recipientChanged') player(g, 'atreides').ally = null;
else {
depart(g);
if (reason === 'occupationChanged') {
player(g, 'guild').reserves--;
g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
observe(g, 'controlled-new-guild-qualification');
}
}
const before = structuredClone(g);
assert.equal(lifecycle(g).reason, reason); assert.equal(lifecycle(g).release, true);
assert.equal(lifecycle(g).blocked, null);
const released = releaseCapturedHomeworldDuke(g, 'harkonnen');
assert.equal(released.controller, null); assert.equal(released.source, null);
assert.equal(released.leader.capturedBy, undefined);
assert.deepEqual(g, before, 'release recheck is pure even before settled-boundary reconciliation');
}
});
void test('secret execution retains the same public lease until public expiry and retires its visibility only then', () => {
const living = position(), executed = structuredClone(living);
executed.dukeVidal = executeCapturedDuke(executed.dukeVidal!, 'harkonnen', 1);
assert.equal(returnedDukeCapture(executed.dukeVidal), true);
assert.deepEqual(lifecycle(executed), lifecycle(living), 'actual private death is not a lease-expiry reason');
const original = structuredClone(executed.dukeVidal);
const atDeadline = expireDuke(executed.dukeVidal, 1);
assert.deepEqual(executed.dukeVidal, original);
assert.deepEqual(atDeadline.leader, original.leader);
assert.deepEqual(atDeadline.captureVisibility, noTenure);
assert.equal(returnedDukeCapture(atDeadline), true);
depart(living); depart(executed);
assert.deepEqual(lifecycle(executed), lifecycle(living));
assert.equal(lifecycle(executed).reason, 'occupationEnded');
executed.dukeVidal = retireHomeworldDukeTenure(executed.dukeVidal, 1);
assert.equal(returnedDukeCapture(executed.dukeVidal), true);
assert.equal(executed.dukeVidal.leader.dead, true);
assert.equal(executed.dukeVidal.leader.deaths, 2);
assert.equal(executed.dukeVidal.leader.concealed?.controller, 'guild');
});
void test('retired custody still permits the original captor execution and Ecaz-only return mechanisms', () => {
const g = position();
const retired = retireHomeworldDukeTenure(g.dukeVidal!, 1);
const executed = executeCapturedDuke(retired, 'harkonnen', 1);
assert.equal(returnedDukeCapture(executed), true);
assert.deepEqual(executed.captureVisibility, noTenure);
assert.equal(executed.leader.deaths, 2); assert.equal(executed.leader.concealed?.controller, 'guild');
const revived = acquireDuke(executed, 'ecaz', 1, 'ecazNexus');
assert.equal(revived.controller, 'ecaz'); assert.equal(revived.source, 'ecazNexus');
assert.equal(revived.leader.deaths, 2); assert.equal(revived.leader.dead, false);
assert.equal(revived.captureVisibility, undefined);
const returned = releaseCapturedDuke(retired, 'harkonnen', 1);
assert.equal(returned.controller, null); assert.equal(returned.leader.dead, false);
assert.equal(returned.leader.id, retired.leader.id);
});
void test('non-homeworld captive return and expiry keep their original tenure rules', () => {
const g = position();
for (const source of ['ecaz', 'moritani', 'ecazNexus', 'ally'] as const) {
const captured = captureDuke(acquireDuke(createDukeVidal(), 'guild', 1, source),
'harkonnen', 'guild', 1, 'sietch_tabr');
g.dukeVidal = captured;
assert.deepEqual(expireDuke(captured, 1), captured);
assert.deepEqual(retireHomeworldDukeTenure(captured, 1), captured);
assert.deepEqual(releaseCapturedHomeworldDuke(g, 'harkonnen'), releaseCapturedDuke(captured, 'harkonnen', 1));
}
});
void test('a retired captured or concealed disc still cannot receive a fresh occupied Ecaz grant', () => {
const g = position();
const held = retireHomeworldDukeTenure(g.dukeVidal!, 1);
depart(g);
player(g, 'guild').reserves--;
g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
observe(g, 'controlled-new-qualification');
for (const state of [held, executeCapturedDuke(held, 'harkonnen', 1)]) {
g.dukeVidal = state;
assert.match(quoteHomeworldEcazDuke(g).blocked!, /captured, concealed or Ghola/);
}
// Deliberately invalid negative input: no legal foreign Duke revival is asserted.
g.dukeVidal = createDukeVidal(); g.dukeVidal.leader.gholaBy = 'atreides';
assert.match(quoteHomeworldEcazDuke(g).blocked!, /captured, concealed or Ghola/);
});