180 lines
9.6 KiB
TypeScript
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/);
|
|
});
|