Files
Dune/tests/ghola-lifecycle.test.ts
Quintine 1a505525a8 Open Tleilaxu special Karama throughout Revival
Permit native Advanced quiet any-player declarations alongside the original target-bound pending request. Preserve completed returns and charges. Adopt explicit provisional Ghola inclusion during the remaining Revival and phase expiry; keep printed Emperor special immunity and the existing low-Homeworld guard. Connect persistent controls and minimal legal bots through the shared quote.

Validation: 14 related files148/148, types/lint/build, six actual native control/engine/GameTable SSR journeys and four existing-profile continuations. Private-driver-only feedback reuses passed source-identical checks. Native111/113-action setup, paid shipment and battle sources earn originals. No saved-room/D1/restart/deployment operation; full modes and browser/deployed acceptance remain open.
2026-10-24 19:30:21 +11:00

380 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { FACTIONS, type FactionId } from '../game/catalog';
import {
acquireDuke,
createDukeVidal,
DUKE_VIDAL_ID,
} from '../game/duke-vidal';
function fixture(faction: FactionId = 'emperor', advanced = false) {
const g = createGame(
'GHOLALIFE',
newPlayer('p', 'Player', faction),
advanced,
);
g.players.push(
newPlayer('q', 'Opponent', faction === 'guild' ? 'emperor' : 'guild'),
);
Object.assign(g, {
status: 'playing',
phase: 4,
turn: 2,
storm: 18,
order: ['p', 'q'],
deck: baseDeck(),
});
for (const p of g.players)
Object.assign(p, {
hand: [],
traitors: [],
traitorChoices: [],
forces: {},
reserves: 20,
spice: 10,
});
const card = g.deck.splice(
g.deck.findIndex((c) => c.effect === 'ghola'),
1,
)[0];
g.players[0].hand.push(card);
return { g, card: card.id };
}
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function prepared(g: Game, territory: string, target?: string) {
g = applyAction(g, g.active!, {
type: 'chooseBattle',
territory,
target: target ?? (g.active === 'p' ? 'q' : 'p'),
});
for (
let n = 0;
n < 20 && (g.response || g.battle?.preparation || g.decision);
n++
) {
if (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
else if (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
else if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, {
type: 'decision',
decline: true,
});
else throw Error(JSON.stringify(g.decision));
}
return g;
}
void test('Ghola returns an actually killed leader for another territory in the same Battle phase', () => {
for (const advanced of [false, true]) {
const initial = fixture('emperor', advanced);
const card = initial.card;
let g = initial.g;
Object.assign(g, { phase: 6, active: 'p', order: ['p', 'q', 's'] });
for (const p of g.players)
Object.assign(p, {
forces: { 'arrakeen:10': 2, 'carthag:11': 2 },
reserves: 16,
});
const poison = g.deck.splice(
g.deck.findIndex((c) => c.kind === 'poison'),
1,
)[0];
g.players.push(newPlayer('s', 'Third', 'fremen'));
Object.assign(g.players[2], {
forces: { 'arrakeen:10': 2 },
reserves: 18,
hand: [poison],
traitors: [],
traitorChoices: [],
spice: 10,
});
const leader = g.players[0].leaders[0].id;
// First win establishes usedAt through real resolution. A second opponent
// then kills the same leader in the same territory before the Ghola play.
for (const opponent of ['q', 's']) {
g = prepared(g, 'arrakeen', opponent);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 0,
support: 0,
leader,
});
g = applyAction(g, opponent, {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players.find((p) => p.id === opponent)!.leaders.at(-1)!.id,
...(opponent === 's' ? { weapon: poison.id } : {}),
});
g = applyAction(g, 'p', { type: 'traitorCall', call: false });
g = applyAction(g, opponent, { type: 'traitorCall', call: false });
while (g.decision) {
assert.equal(g.decision.kind, 'battleCards');
g = applyAction(g, g.decision.player, {
type: 'decision',
discard: [],
});
}
if (opponent === 'q') {
assert.equal(
g.players[0].leaders.find((l) => l.id === leader)!.dead,
false,
);
assert.equal(
g.players[0].leaders.find((l) => l.id === leader)!.usedAt,
'arrakeen',
);
}
}
assert.equal(g.phase, 6);
assert.equal(g.battle, null);
const dead = g.players[0].leaders.find((l) => l.id === leader)!;
assert.equal(dead.dead, true);
assert.equal(dead.usedAt, undefined);
assert.equal(dead.deaths, 1);
const before = reload(g),
spice = g.players[0].spice;
assert.ok(viewGame(g, 'p').ghola.leaders.some((l) => l.id === leader));
g = applyAction(reload(g), 'p', { type: 'card', card, leader });
assert.deepEqual(
before.players[0].leaders.find((l) => l.id === leader),
dead,
);
assert.equal(
g.players[0].leaders.find((l) => l.id === leader)!.usedAt,
undefined,
);
assert.equal(g.players[0].leaders.find((l) => l.id === leader)!.deaths, 1);
assert.equal(g.players[0].spice, spice);
assert.equal(g.players[0].leaderRevived, before.players[0].leaderRevived);
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
g = prepared(reload(g), 'carthag');
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 0,
support: 0,
leader,
});
assert.equal(g.battle!.plans.p.leader, leader);
}
});
void test('all non-Ecaz factions are refused a synthetic dead temporarily controlled Duke without spending Ghola', () => {
for (const f of FACTIONS.filter((f) => f.id !== 'ecaz')) {
const { g, card } = fixture(f.id as FactionId);
g.dukeVidal = acquireDuke(createDukeVidal(), 'p', 2, 'ally');
Object.assign(g.dukeVidal.leader, { dead: true, deaths: 1 });
const before = reload(g);
assert.ok(
!viewGame(g, 'p').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID),
);
assert.throws(
() => applyAction(g, 'p', { type: 'card', card, leader: DUKE_VIDAL_ID }),
/Only Ecaz/,
);
assert.deepEqual(g, before);
}
});
void test('Ghola projection excludes captured or foreign controlled native leaders and exceptional Duke custody', () => {
const { g } = fixture('ecaz');
g.dukeVidal = acquireDuke(createDukeVidal(), 'p', 2, 'ecaz');
Object.assign(g.dukeVidal.leader, { dead: true, deaths: 1 });
Object.assign(g.players[0].leaders[0], {
dead: true,
deaths: 1,
capturedBy: 'q',
});
Object.assign(g.players[0].leaders[1], {
dead: true,
deaths: 1,
gholaBy: 'q',
});
Object.assign(g.players[0].leaders[2], { dead: true, deaths: 1 });
assert.deepEqual(
viewGame(g, 'p')
.ghola.leaders.map((l) => l.id)
.sort(),
[DUKE_VIDAL_ID, g.players[0].leaders[2].id].sort(),
);
g.dukeVidal.leader.concealed = { captor: 'q', dead: false, deaths: 0 };
const a = viewGame(g, 'p').ghola;
g.dukeVidal.leader.dead = false;
g.dukeVidal.leader.capturedBy = 'q';
assert.deepEqual(viewGame(g, 'p').ghola, a);
assert.deepEqual(
a.leaders.map((l) => l.id),
[g.players[0].leaders[2].id],
);
});
void test('Ghola options are own-hand only and ignore opponents private spice cards and traitors', () => {
const { g } = fixture();
Object.assign(g.players[0], { tanks: 5, reserves: 15 });
const before = reload(g),
a = viewGame(g, 'p').ghola;
assert.equal(a.maxForces, 5);
assert.equal(a.available, true);
assert.equal(viewGame(g, 'q').ghola.available, false);
g.players[1].spice = 987;
g.players[1].hand = g.deck.splice(0, 4);
g.players[1].traitors = ['emperor-0'];
assert.deepEqual(viewGame(g, 'p').ghola, a);
assert.deepEqual(before.players[0], g.players[0]);
});
void test('Ghola respects table windows and Revival prevention, which expires outside Revival', () => {
const { g, card } = fixture();
Object.assign(g.players[0], {
tanks: 5,
reserves: 15,
revived: 3,
leaderRevived: true,
});
g.revivalPrevention = { player: 'p', turn: 2 };
assert.equal(viewGame(g, 'p').ghola.available, false);
const blocked = structuredClone(g);
assert.throws(() => applyAction(g, 'p', { type: 'card', card, amount: 5 }), /prevented.*Ghola/);
assert.deepEqual(g, blocked);
// Unit timing branch; the native fixture separately proves actual phase exit.
g.phase = 5;
g.active = 'p';
assert.equal(viewGame(g, 'p').ghola.available, true);
const done = applyAction(g, 'p', { type: 'card', card, amount: 5 });
assert.equal(done.players[0].reserves, 20);
assert.equal(done.players[0].revived, 3);
assert.equal(done.players[0].leaderRevived, true);
for (const mutate of [
(x: Game) => {
x.phaseOpening = { passed: [], initialize: false };
},
(x: Game) => {
x.response = {
kind: 'revivalIncome',
owner: 'q',
recipient: 'p',
amount: 1,
passed: [],
};
},
(x: Game) => {
x.decision = { kind: 'fullPlanOffer', player: 'p' };
},
]) {
const blocked = reload(g);
mutate(blocked);
const before = reload(blocked);
assert.equal(viewGame(blocked, 'p').ghola.available, false);
assert.throws(() =>
applyAction(blocked, 'p', { type: 'card', card, amount: 5 }),
);
assert.deepEqual(blocked, before);
}
});
void test('Ghola force options honor physical elite composition and remaining elite allowance', () => {
const { g, card } = fixture('emperor', true);
Object.assign(g.players[0], { tanks: 5, reserves: 15 });
g.players[0].elites = { forces: {}, reserves: 3, tanks: 2, revived: 1 };
const options = viewGame(g, 'p').ghola;
assert.equal(options.maxForces, 3);
assert.equal(options.eliteRemaining, 0);
assert.throws(
() => applyAction(g, 'p', { type: 'card', card, amount: 4, elite: 1 }),
/Only one elite/,
);
const done = applyAction(g, 'p', { type: 'card', card, amount: 3, elite: 0 });
assert.equal(done.players[0].tanks, 2);
assert.deepEqual(done.players[0].elites, g.players[0].elites);
});
void test('Ghola returns Kwisatz without a stale battle location or normal leader-slot use', () => {
const { g, card } = fixture('atreides', true);
g.players[0].kwisatz = { dead: true, usedAt: 'arrakeen', revivalCycle: 1 };
const before = reload(g);
const done = applyAction(g, 'p', { type: 'card', card, leader: 'kwisatz' });
assert.equal(done.players[0].kwisatz?.dead, false);
assert.equal(done.players[0].kwisatz?.usedAt, undefined);
assert.equal(done.players[0].leaderRevived, before.players[0].leaderRevived);
assert.equal(done.players[0].spice, before.players[0].spice);
});
void test('Ghola revives an actually exploded Kwisatz for a later territory in the same turn', () => {
const initial = fixture('atreides', true);
const card = initial.card;
let g = initial.g;
Object.assign(g, { phase: 6, active: 'p', order: ['p', 'q'] });
for (const p of g.players)
Object.assign(p, {
forces: { 'arrakeen:10': 3, 'carthag:11': 3 },
reserves: 14,
});
// Activation is a conserved prior-game precondition; both battles below are real actions.
g.players[0].battleLosses = 7;
const shield = g.deck.splice(
g.deck.findIndex((c) => c.kind === 'shield'),
1,
)[0];
const lasgun = g.deck.splice(
g.deck.findIndex((c) => c.kind === 'lasgun'),
1,
)[0];
g.players[0].hand.push(shield);
g.players[1].hand.push(lasgun);
g = prepared(g, 'arrakeen');
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players[0].leaders[0].id,
defense: shield.id,
kwisatz: true,
});
g = applyAction(g, 'q', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players[1].leaders[0].id,
weapon: lasgun.id,
});
g = applyAction(g, 'p', { type: 'traitorCall', call: false });
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
assert.equal(g.battle, null);
assert.equal(g.phase, 6);
assert.equal(g.players[0].kwisatz?.dead, true);
assert.equal(g.players[0].kwisatz?.usedAt, 'arrakeen');
assert.equal(g.players[0].tanks, 3);
const before = reload(g);
g = applyAction(reload(g), 'p', { type: 'card', card, leader: 'kwisatz' });
assert.equal(g.players[0].kwisatz?.dead, false);
assert.equal(g.players[0].kwisatz?.usedAt, undefined);
assert.equal(g.players[0].kwisatz?.revivalCycle, 2);
assert.equal(g.players[0].leaderRevived, before.players[0].leaderRevived);
assert.equal(g.players[0].spice, before.players[0].spice);
assert.equal(g.players[0].tanks, 3);
assert.equal(
g.players[0].leaders[0].dead,
true,
'Ghola revived only Kwisatz',
);
g = prepared(reload(g), 'carthag');
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players[0].leaders[1].id,
kwisatz: true,
});
assert.equal(g.battle!.plans.p.kwisatz, true);
assert.equal(viewGame(g, 'q').battle!.plans.p, undefined);
});