Files
Dune/tests/foreign-gholas.test.ts
2026-09-08 07:37:52 +10:00

546 lines
17 KiB
TypeScript

import { placeFixtureHand } from './fixture-hand';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck, leaders } from '../game/cards';
import { normalRevivalCycle } from '../game/revival';
import { controlsLeader } from '../game/leader-control';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function fixture() {
let g = createGame('TREVIVAL2', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('t', 'Tleilaxu', 'guild'));
joinGame(g, newPlayer('h', 'Observer', 'harkonnen'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.players[2].faction = 'tleilaxu';
g.players[2].leaders = leaders('tleilaxu');
g.players[2].traitors = [];
g.phase = 4;
g.advanced = true;
g.storm = 18;
g.order = ['a', 'e', 't', 'h'];
g.active = null;
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.spice = 20;
p.revived = 0;
}
return g;
}
const karama = baseDeck().find((c) => c.effect === 'karama')!;
const gholaCard = baseDeck().find((c) => c.effect === 'ghola')!;
function die(g: Game, player = 0, index = 0, deaths = 1) {
const l = g.players[player].leaders[index];
l.dead = true;
l.deaths = deaths;
return l;
}
function allow(state: Game, only?: string) {
let g = state;
while (
(g.response && (!only || g.response.kind === only)) ||
g.decision?.kind === 'revivalStop'
)
g =
g.decision?.kind === 'revivalStop'
? applyAction(g, g.decision.player, { type: 'decision', decline: true })
: applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function acquire(g: Game, leader = 'atreides-0') {
return applyAction(g, 't', { type: 'reviveForeignGhola', leader });
}
function acquired(leader = 'atreides-0') {
const g = fixture();
die(g, 2);
die(g, leader.startsWith('emperor') ? 1 : 0);
return allow(acquire(g, leader));
}
function startBattle(state: Game) {
const before = structuredClone(state);
before.phase = 6;
before.order = ['t', 'a', 'e', 'h'];
before.active = 't';
for (const p of before.players) {
p.forces = {};
p.reserves = 20;
p.traitors = [];
}
for (const id of ['t', 'a']) {
const p = before.players.find((p) => p.id === id)!;
p.forces = { 'arrakeen:10': 5 };
p.reserves = 15;
}
let g = allow(
applyAction(before, 't', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'a',
}),
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
return g;
}
void test('foreign ghola acquisition pays the bank, changes control without cloning identity, and survives JSON', () => {
const g = acquired();
const l = g.players[0].leaders[0];
assert.equal(l.dead, false);
assert.equal(l.gholaBy, 't');
assert.equal(l.faction, 'atreides');
assert.equal(l.deaths, 1);
assert.equal(g.players[2].spice, 17);
assert.equal(
g.players.flatMap((p) => p.leaders).filter((leader) => leader.id === l.id)
.length,
1,
);
assert.ok(controlsLeader(g.players[2], l));
assert.ok(!controlsLeader(g.players[0], l));
const restored = JSON.parse(JSON.stringify(g)) as Game;
for (const viewer of g.players) {
const view = viewGame(restored, viewer.id);
assert.equal(
view.players[2].leaders.find((leader) => leader.id === l.id)?.gholaBy,
't',
);
}
});
void test('multiple foreign gholas fill exactly five active leaders in one phase', () => {
let g = fixture();
die(g, 2, 0);
die(g, 2, 1);
die(g);
die(g, 0, 1);
die(g, 0, 2);
g = allow(acquire(g));
g = allow(acquire(g, 'atreides-1'));
const snapshot = structuredClone(g);
assert.throws(() => acquire(g, 'atreides-2'), /up to five/);
assert.deepEqual(g, snapshot);
assert.equal(
viewGame(g, 't').players[2].leaders.filter(
(l) => !l.dead && controlsLeader(g.players[2], l),
).length,
5,
);
});
void test('foreign acquisition enforces advanced mode, faction, phase, death, ownership, funds and Auditor exclusion', () => {
const g = fixture();
die(g, 2);
die(g);
for (const [change, pattern] of [
[
(x: Game) => {
x.advanced = false;
},
/advanced/,
],
[
(x: Game) => {
x.phase = 5;
},
/advanced/,
],
[
(x: Game) => {
x.players[0].leaders[0].dead = false;
},
/dead leader/,
],
[
(x: Game) => {
x.players[0].leaders[0].capturedBy = 'h';
},
/dead leader/,
],
[
(x: Game) => {
x.players[2].spice = 2;
},
/spice/,
],
] as const) {
const altered = structuredClone(g);
change(altered);
const snapshot = structuredClone(altered);
assert.throws(() => acquire(altered), pattern);
assert.deepEqual(altered, snapshot);
}
const auditorOwner = newPlayer('c', 'CHOAM', 'choam');
auditorOwner.leaders.push({
id: 'choam-auditor',
name: 'Auditor',
faction: 'choam',
strength: 2,
dead: true,
deaths: 1,
});
g.players.push(auditorOwner);
assert.throws(() => acquire(g, 'choam-auditor'), /Auditor/);
assert.throws(
() =>
applyAction(g, 'a', { type: 'reviveForeignGhola', leader: 'atreides-0' }),
/advanced/,
);
assert.throws(() => acquire(g, 'tleilaxu-0'), /another faction/);
assert.throws(() => acquire(g, 'kwisatz'), /another faction/);
});
void test('specific-ghola Karama cancellation preserves cost and identity and blocks only that leader for this turn', () => {
let g = fixture();
die(g, 2);
die(g);
die(g, 0, 1);
g.players[3].hand = [karama];
g = acquire(g);
assert.equal(g.response?.kind, 'foreignGhola');
const hidden = JSON.stringify(viewGame(g, 'e').response);
assert.ok(!hidden.includes('atreides-0'));
g = applyAction(g, 'h', { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(g.players[2].spice, 20);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[0].leaders[0].gholaBy, undefined);
assert.throws(() => acquire(g), /canceled this turn/);
assert.deepEqual(viewGame(g, 't').players[2].gholaBlocked, ['atreides-0']);
assert.equal(viewGame(g, 'a').players[2].gholaBlocked, undefined);
g = allow(acquire(g, 'atreides-1'));
assert.equal(g.players[0].leaders[1].dead, false);
g.turn++;
die(g, 2, 1);
g = allow(acquire(g));
assert.equal(g.players[0].leaders[0].dead, false);
});
void test('ghola discount has its own response and canceled discount cannot partially acquire an unaffordable leader', () => {
let g = fixture();
die(g, 2);
die(g);
g.players[2].spice = 3;
g.players[3].hand = [karama];
g = allow(acquire(g), 'foreignGhola');
assert.equal(g.response?.kind, 'revivalDiscount');
g = applyAction(g, 'h', { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(g.players[2].spice, 3);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[0].leaders[0].gholaBy, undefined);
assert.equal(g.pendingRevival, null);
});
void test('face-down executed leaders can become gholas without losing death history or leaking pending identity', () => {
let g = fixture();
g.players[3].hand = [karama];
die(g, 2);
const l = die(g, 0, 0, 2);
l.concealed = { captor: 'h', dead: false, deaths: 0 };
g = acquire(g);
assert.equal(viewGame(g, 'e').players[0].leaders[0].dead, false);
assert.equal(viewGame(g, 't').players[0].leaders[0].dead, true);
g = allow(g);
assert.equal(g.players[0].leaders[0].concealed, undefined);
assert.equal(g.players[0].leaders[0].deaths, 2);
assert.equal(
viewGame(g, 'e').players[2].leaders.find((leader) => leader.id === l.id)
?.gholaBy,
't',
);
});
void test('a living ghola cannot be sold back; death enables a private negotiated return even with all native leaders unavailable', () => {
let g = acquired();
assert.throws(
() =>
applyAction(g, 'a', {
type: 'requestLeaderRevival',
leader: 'atreides-0',
}),
/dead native/,
);
g.players[0].leaders.forEach((l) => {
l.dead = true;
l.deaths = 2;
});
assert.throws(
() => applyAction(g, 'a', { type: 'reviveLeader', leader: 'atreides-0' }),
/dead leader/,
);
g = applyAction(g, 'a', {
type: 'requestLeaderRevival',
leader: 'atreides-0',
});
g = applyAction(g, 't', {
type: 'quoteLeaderRevival',
target: 'a',
amount: 7,
});
assert.deepEqual(viewGame(g, 'e').revivalRequests, {});
g = allow(applyAction(g, 'a', { type: 'acceptLeaderRevival' }));
assert.equal(g.players[0].spice, 13);
assert.equal(g.players[2].spice, 24);
assert.equal(g.players[0].leaders[0].gholaBy, undefined);
assert.equal(g.players[0].leaders[0].dead, false);
assert.equal(g.players[0].leaderRevived, true);
assert.ok(
!viewGame(g, 't').players[2].leaders.some((l) => l.id === 'atreides-0'),
);
});
void test('a rejected or stale buyback cannot transfer the leader or charge the original owner', () => {
let g = acquired();
die(g, 0, 0, 2);
g = applyAction(g, 'a', {
type: 'requestLeaderRevival',
leader: 'atreides-0',
});
g = applyAction(g, 't', { type: 'declineLeaderRevival', target: 'a' });
assert.throws(
() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }),
/declined/,
);
g = applyAction(g, 't', {
type: 'quoteLeaderRevival',
target: 'a',
amount: 0,
});
g = allow(acquire(g));
const snapshot = structuredClone(g);
assert.throws(
() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }),
/dead native/,
);
assert.deepEqual(g, snapshot);
});
void test('ghola control counts as native unavailability for ordinary revival cycles', () => {
const g = acquired();
for (let i = 1; i < 5; i++) die(g, 0, i);
assert.equal(normalRevivalCycle(g.players[0]), 1);
const revived = allow(
applyAction(g, 'a', { type: 'reviveLeader', leader: 'atreides-1' }),
);
assert.equal(revived.players[0].leaders[1].dead, false);
assert.equal(revived.players[0].leaders[0].gholaBy, 't');
});
void test('Ghola treachery revives an existing controlled ghola, but its native faction cannot bypass a buyback', () => {
let g = acquired();
die(g, 0, 0, 2);
g.phase = 7;
placeFixtureHand(g, 0, [gholaCard]);
assert.throws(
() =>
applyAction(g, 'a', {
type: 'card',
card: gholaCard.id,
leader: 'atreides-0',
}),
/your pool/,
);
g.players[0].hand = [];
placeFixtureHand(g, 2, [gholaCard]);
g = allow(
applyAction(g, 't', {
type: 'card',
card: gholaCard.id,
leader: 'atreides-0',
}),
);
assert.equal(g.players[0].leaders[0].dead, false);
assert.equal(g.players[0].leaders[0].gholaBy, 't');
assert.equal(g.players[2].spice, 18);
});
void test('ghola leaders fight for Tleilaxu and remain susceptible to their original traitor cards', () => {
let g = startBattle(acquired('emperor-0'));
g.players[0].traitors = ['emperor-0'];
g = applyAction(g, 't', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
g = applyAction(g, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
});
g = applyAction(g, 't', { type: 'traitorCall', call: false });
g = applyAction(g, 'a', { type: 'traitorCall', call: true });
assert.equal(g.players[1].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].deaths, 2);
assert.equal(g.players[1].leaders[0].gholaBy, 't');
assert.equal(g.players[2].tanks, 5);
});
void test('a native faction cannot seal its living foreign ghola in a battle plan', () => {
let g = startBattle(acquired());
g = applyAction(g, 't', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
});
const snapshot = structuredClone(g);
assert.throws(
() =>
applyAction(g, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
}),
/leader/i,
);
assert.deepEqual(g, snapshot);
});
void test('all AI difficulties acquire only affordable gholas from their private projection and recognize control', () => {
for (const difficulty of DIFFICULTIES) {
const before = fixture();
die(before, 2);
die(before);
before.players[2].bot = difficulty;
const actions = botActions(viewGame(before, 't'));
const action = actions.find((a) => a.type === 'reviveForeignGhola')!;
assert.ok(action);
const g = allow(applyAction(before, 't', action));
const combat = startBattle(g);
const plans = botActions(viewGame(combat, 't')).filter(
(a) => a.type === 'battlePlan',
);
assert.ok(plans.some((a) => a.leader === 'atreides-0'));
const altered = structuredClone(before);
altered.players[0].spice = 9999;
assert.deepEqual(botActions(viewGame(altered, 't')), actions);
}
});
void test('AI revival with foreign gholas and private buybacks terminates without duplicating leaders', () => {
const before = fixture();
die(before, 2, 0);
die(before, 2, 1);
die(before);
die(before, 0, 1);
before.players.forEach((p) => {
p.bot = 'Hard';
});
let g = runBots(before);
for (let i = 0; i < 12 && g.phase === 4; i++) g = runBots(g);
assert.notEqual(g.phase, 4);
const identities = g.players.flatMap((p) => p.leaders.map((l) => l.id));
assert.equal(identities.length, 20);
assert.equal(new Set(identities).size, 20);
assert.ok(g.players.every((p) => p.spice >= 0));
});
void test('Harkonnen capture reveals a ghola identity to its current controller and returns control after one use', () => {
let g = acquired('emperor-0');
for (const l of g.players[2].leaders) {
l.dead = true;
l.deaths = 1;
}
g.phase = 6;
g.active = 'h';
g.order = ['h', 't', 'a', 'e'];
for (const id of ['h', 't']) {
const p = g.players.find((p) => p.id === id)!;
p.forces = { 'arrakeen:10': 5 };
p.reserves = 15;
}
g = allow(
applyAction(g, 'h', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 't',
}),
);
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
g = applyAction(g, 'h', {
type: 'battlePlan',
dial: 1,
support: 1,
leader: 'harkonnen-0',
});
g = applyAction(g, 't', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
g = applyAction(g, 'h', { type: 'traitorCall', call: false });
g = applyAction(g, 't', { type: 'traitorCall', call: false });
if (g.decision?.kind === 'battleLosses')
g = applyAction(g, 'h', { type: 'decision', choice: 0 });
assert.equal(g.decision?.kind, 'captureOffer');
g = allow(applyAction(g, 'h', { type: 'decision', accept: true }));
assert.equal(g.decision?.kind, 'capturedLeader');
assert.equal(
(viewGame(g, 't').decision as { leader: string }).leader,
'emperor-0',
);
assert.equal((viewGame(g, 'e').decision as { leader: string }).leader, '');
assert.equal(
viewGame(g, 't').players[2].leaders.find((l) => l.id === 'emperor-0')
?.capturedBy,
'h',
);
assert.equal(viewGame(g, 'e').players[1].leaders[0].capturedBy, undefined);
g = applyAction(g, 'h', { type: 'decision', mode: 'keep' });
if (g.decision?.kind === 'faceDance')
g = applyAction(g, 't', { type: 'decision', reveal: false });
g.phase = 6;
g.active = 'h';
g.order = ['h', 'a', 't', 'e'];
g.response = null;
g.players[0].forces = { 'arrakeen:10': 5 };
g.players[0].reserves = 15;
g = allow(
applyAction(g, 'h', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'a',
}),
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
g = applyAction(g, 'h', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
g = applyAction(g, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
});
g = applyAction(g, 'h', { type: 'traitorCall', call: false });
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
assert.equal(g.players[1].leaders[0].capturedBy, undefined);
assert.equal(g.players[1].leaders[0].gholaBy, 't');
assert.equal(g.players[1].leaders[0].dead, false);
assert.ok(controlsLeader(g.players[2], g.players[1].leaders[0]));
});
void test('AI requests an eligible dead-ghola buyback when every native leader is unavailable', () => {
const g = acquired();
for (const leader of g.players[0].leaders) {
leader.dead = true;
leader.deaths = 2;
}
g.players[0].leaders[1].strength = 9;
for (const difficulty of DIFFICULTIES) {
g.players[0].bot = difficulty;
const actions = botActions(viewGame(g, 'a'));
const request = actions.find(
(action) => action.type === 'requestLeaderRevival',
);
assert.equal(request?.leader, 'atreides-0');
assert.doesNotThrow(() => applyAction(g, 'a', request!));
}
});