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.
380 lines
12 KiB
TypeScript
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);
|
|
});
|