419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import {
|
|
homeworldRevivalDeploymentActions,
|
|
homeworldRevivalActionBlock,
|
|
} from '../game/homeworld-revival-deployment-options';
|
|
import {
|
|
homeworldRevivalFixture,
|
|
enterHomeworldRevival,
|
|
positionRevivalForces,
|
|
holdRevivalCard,
|
|
revivalPlayer as p,
|
|
revivalReload as reload,
|
|
revivalInventory as inventory,
|
|
rejectRevivalAction as reject,
|
|
} from './fixture-homeworld-revival';
|
|
|
|
function choice(g: Game, actor: string, destination = 'arrakeen:10'): Action {
|
|
const offer = viewGame(g, actor).homeworldRevivalDeployment;
|
|
assert.ok(offer);
|
|
assert.equal(offer.player, actor);
|
|
assert.ok(
|
|
offer.destinations.some(
|
|
(target) => target.id === destination && !target.blocked,
|
|
),
|
|
);
|
|
return {
|
|
type: 'decision',
|
|
event: offer.event,
|
|
destination,
|
|
amount: offer.normal + offer.elite,
|
|
};
|
|
}
|
|
function stable(g: Game) {
|
|
inventory(g);
|
|
const before = reload(g);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), before);
|
|
for (const player of g.players) {
|
|
const view = viewGame(reload(g), player.id);
|
|
for (const other of view.players.filter(
|
|
(other) => other.id !== player.id,
|
|
)) {
|
|
assert.equal(other.hand, undefined);
|
|
assert.equal(other.spice, undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
void test('actual high Southern revival moves every newly revived Basic star once while ordinary returns stay native', () => {
|
|
let g = enterHomeworldRevival(homeworldRevivalFixture());
|
|
const old = structuredClone(p(g, 'f'));
|
|
g = applyAction(g, 'f', { type: 'revive', amount: 3, elite: 2 });
|
|
assert.equal(g.decision?.kind, 'homeworldRevivalDeployment');
|
|
assert.equal(p(g, 'f').reserves, old.reserves + 3);
|
|
assert.equal(p(g, 'f').elites!.reserves, old.elites!.reserves + 2);
|
|
assert.deepEqual(g.homeworldRevivalReturn!.group, {
|
|
amount: 3,
|
|
elite: 2,
|
|
free: 3,
|
|
});
|
|
assert.equal(viewGame(g, 'f').homeworldRevivalDeployment!.normal, 0);
|
|
assert.equal(viewGame(g, 'f').homeworldRevivalDeployment!.elite, 2);
|
|
assert.ok(
|
|
viewGame(g, 'f').homeworldRevivalDeployment!.destinations.every(
|
|
(target) => !target.homeworld,
|
|
),
|
|
);
|
|
stable(g);
|
|
const action = choice(g, 'f');
|
|
for (const amount of [0, 3])
|
|
reject(g, 'f', { ...action, amount }, /Fedaykin must be an integer/);
|
|
g = applyAction(reload(g), 'f', action);
|
|
assert.equal(p(g, 'f').reserves, old.reserves + 1);
|
|
assert.equal(p(g, 'f').elites!.reserves, old.elites!.reserves);
|
|
assert.equal(p(g, 'f').forces['arrakeen:10'], old.forces['arrakeen:10'] + 2);
|
|
assert.equal(p(g, 'f').elites!.forces['arrakeen:10'], 2);
|
|
assert.equal(p(g, 'f').tanks, old.tanks - 3);
|
|
assert.equal(p(g, 'f').spice, old.spice);
|
|
assert.equal(p(g, 'f').shipped, old.shipped);
|
|
assert.equal(p(g, 'f').moved, old.moved);
|
|
assert.equal(g.homeworldRevivalReturn!.stage, 'complete');
|
|
reject(g, 'f', action);
|
|
stable(g);
|
|
});
|
|
|
|
void test('Advanced Southern keeps the shared one-star revival cap through decline and a later Ghola', () => {
|
|
let g = enterHomeworldRevival(homeworldRevivalFixture({ advanced: true }));
|
|
const ghola = holdRevivalCard(g, 'f', 'ghola');
|
|
reject(g, 'f', { type: 'revive', amount: 2, elite: 2 }, /one elite/);
|
|
g = applyAction(g, 'f', { type: 'revive', amount: 1, elite: 1 });
|
|
const offer = viewGame(g, 'f').homeworldRevivalDeployment!;
|
|
const returned = structuredClone(p(g, 'f'));
|
|
g = applyAction(reload(g), 'f', {
|
|
type: 'decision',
|
|
event: offer.event,
|
|
decline: true,
|
|
});
|
|
assert.deepEqual(p(g, 'f'), returned);
|
|
assert.equal(g.homeworldRevivalReturn!.destination, 'decline');
|
|
reject(
|
|
g,
|
|
'f',
|
|
{ type: 'card', card: ghola, amount: 1, elite: 1 },
|
|
/one elite/,
|
|
);
|
|
assert.ok(p(g, 'f').hand.some((card) => card.id === ghola));
|
|
stable(g);
|
|
});
|
|
|
|
void test('high Tleilax mixed paid and free revival deploys only the actual free group and excludes old reserves', () => {
|
|
let g = enterHomeworldRevival(homeworldRevivalFixture({ tleilaxu: true }));
|
|
const old = structuredClone(p(g, 't'));
|
|
g = applyAction(g, 't', { type: 'revive', amount: 3, elite: 0 });
|
|
assert.equal(p(g, 't').reserves, old.reserves + 3);
|
|
assert.deepEqual(g.homeworldRevivalReturn!.group, {
|
|
amount: 3,
|
|
elite: 0,
|
|
free: 2,
|
|
});
|
|
assert.equal(viewGame(g, 't').homeworldRevivalDeployment!.normal, 2);
|
|
const action = choice(g, 't');
|
|
reject(g, 't', { ...action, amount: 1 }, /entire/);
|
|
reject(g, 't', { ...action, amount: 3 }, /entire/);
|
|
const paid = p(g, 't').spice;
|
|
g = applyAction(reload(g), 't', action);
|
|
assert.equal(p(g, 't').reserves, old.reserves + 1);
|
|
assert.equal(p(g, 't').forces['arrakeen:10'], old.forces['arrakeen:10'] + 2);
|
|
assert.equal(p(g, 't').tanks, old.tanks - 3);
|
|
assert.equal(
|
|
p(g, 't').spice,
|
|
old.spice - 1 + 1,
|
|
'Pay the Tleilaxu one-spice self-revival price for the single paid force; independent free-revival income settles once.',
|
|
);
|
|
assert.ok(paid <= p(g, 't').spice);
|
|
assert.equal(p(g, 't').revived, 3);
|
|
assert.equal(p(g, 't').freeForcesRevived, 2);
|
|
stable(g);
|
|
});
|
|
|
|
void test('Emperor extra revival earns Southern placement without spending another ordinary revival allowance', () => {
|
|
let g = homeworldRevivalFixture();
|
|
p(g, 'f').ally = 'e';
|
|
p(g, 'e').ally = 'f';
|
|
p(g, 'f').allySinceTurn = p(g, 'e').allySinceTurn = 1;
|
|
g = enterHomeworldRevival(g);
|
|
g = applyAction(g, 'f', { type: 'revive', amount: 3, elite: 0 });
|
|
assert.equal(g.homeworldRevivalReturn ?? null, null);
|
|
const payer = p(g, 'e').spice;
|
|
g = applyAction(g, 'e', { type: 'emperorRevival', amount: 2, elite: 2 });
|
|
assert.equal(g.homeworldRevivalReturn!.source, 'emperorExtra');
|
|
assert.deepEqual(g.homeworldRevivalReturn!.group, {
|
|
amount: 2,
|
|
elite: 2,
|
|
free: 0,
|
|
});
|
|
assert.equal(p(g, 'e').spice, payer - 4);
|
|
const action = choice(g, 'f');
|
|
g = applyAction(reload(g), 'f', action);
|
|
assert.equal(p(g, 'f').tanks, 0);
|
|
assert.equal(p(g, 'f').revived, 3);
|
|
assert.equal(p(g, 'f').freeForcesRevived, 3);
|
|
assert.equal(g.emperorExtra.f, 2);
|
|
assert.equal(p(g, 'e').spice, payer - 4);
|
|
stable(g);
|
|
});
|
|
|
|
void test('Tleilax free group can enter a nonallied Homeworld while allied Homeworlds reject without mutation', () => {
|
|
const setup = enterHomeworldRevival(
|
|
homeworldRevivalFixture({ tleilaxu: true }),
|
|
);
|
|
const pending = applyAction(setup, 't', {
|
|
type: 'revive',
|
|
amount: 2,
|
|
elite: 0,
|
|
});
|
|
const target = viewGame(
|
|
pending,
|
|
't',
|
|
).homeworldRevivalDeployment!.destinations.find(
|
|
(destination) => destination.id === 'homeworld:emperor',
|
|
);
|
|
assert.ok(target && !target.blocked);
|
|
const action = choice(pending, 't', target.id);
|
|
const returned = p(pending, 't').reserves;
|
|
const done = applyAction(reload(pending), 't', action);
|
|
const home = viewGame(done, 't').homeworlds!.worlds!.find(
|
|
(world) => world.id === target.id,
|
|
)!;
|
|
assert.deepEqual(home.forces.t, { normal: 2, elite: 0 });
|
|
assert.equal(
|
|
p(done, 't').reserves,
|
|
returned - 2,
|
|
'Visiting counters leave native reserves and remain in explicit foreign custody.',
|
|
);
|
|
assert.equal(p(done, 't').shipped, false);
|
|
stable(done);
|
|
const allied = reload(pending);
|
|
p(allied, 't').ally = 'e';
|
|
p(allied, 'e').ally = 't';
|
|
p(allied, 't').allySinceTurn = p(allied, 'e').allySinceTurn = 1;
|
|
assert.match(
|
|
viewGame(allied, 't').homeworldRevivalDeployment!.destinations.find(
|
|
(destination) => destination.id === target.id,
|
|
)!.blocked!,
|
|
/ally/,
|
|
);
|
|
reject(allied, 't', action, /ally/);
|
|
});
|
|
|
|
void test('Southern Ghola retires its physical card before choice and restores the independent Tleilaxu income response after placement', () => {
|
|
let g = enterHomeworldRevival(
|
|
homeworldRevivalFixture({ advanced: true, tleilaxu: true }),
|
|
);
|
|
const ghola = holdRevivalCard(g, 'f', 'ghola');
|
|
holdRevivalCard(g, 'e', 'karama');
|
|
const initial = p(g, 't').spice;
|
|
g = applyAction(g, 'f', { type: 'card', card: ghola, amount: 2, elite: 1 });
|
|
assert.equal(g.homeworldRevivalReturn!.source, 'ghola');
|
|
assert.equal(g.homeworldRevivalReturn!.card, ghola);
|
|
assert.equal(
|
|
p(g, 'f').hand.some((card) => card.id === ghola),
|
|
false,
|
|
);
|
|
assert.equal(g.discard.filter((card) => card.id === ghola).length, 1);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.homeworldRevivalReturn!.resumeResponse?.kind, 'revivalIncome');
|
|
assert.equal(p(g, 't').spice, initial);
|
|
assert.equal(p(g, 'f').revived, 0);
|
|
stable(g);
|
|
for (const cancel of [false, true]) {
|
|
let done = applyAction(reload(g), 'f', choice(g, 'f'));
|
|
assert.equal(done.response?.kind, 'revivalIncome');
|
|
const command = cancel
|
|
? {
|
|
type: 'card',
|
|
mode: 'cancel',
|
|
card: p(done, 'e').hand.find((card) => card.effect === 'karama')!.id,
|
|
}
|
|
: { type: 'passResponse' };
|
|
done = applyAction(reload(done), 'e', command);
|
|
assert.equal(done.response, null);
|
|
assert.equal(p(done, 't').spice, initial + (cancel ? 0 : 1));
|
|
assert.equal(done.discard.filter((card) => card.id === ghola).length, 1);
|
|
stable(done);
|
|
}
|
|
});
|
|
|
|
void test('low populations do not invent a deployment; a return that first reaches high stages its own redeployment choice', () => {
|
|
for (const ghola of [false, true]) {
|
|
let g = homeworldRevivalFixture();
|
|
positionRevivalForces(g, 'f', { native: 1, tanks: 5, eliteTanks: 2 });
|
|
g = enterHomeworldRevival(g);
|
|
const card = ghola ? holdRevivalCard(g, 'f', 'ghola') : undefined;
|
|
const action = ghola
|
|
? { type: 'card', card, amount: 1, elite: 1 }
|
|
: { type: 'revive', amount: 1, elite: 1 };
|
|
const low = applyAction(reload(g), 'f', action);
|
|
assert.equal(low.homeworldRevivalReturn ?? null, null);
|
|
assert.equal(viewGame(low, 'f').homeworldRevivalDeployment, null);
|
|
assert.equal(p(low, 'f').reserves, 2);
|
|
stable(low);
|
|
// The crossing return now qualifies under the provisional post-return
|
|
// population policy and may redeploy instead of only returning to reserves.
|
|
const crossing = applyAction(reload(g), 'f', { ...action, amount: 2 });
|
|
assert.notEqual(crossing.homeworldRevivalReturn ?? null, null);
|
|
assert.equal(viewGame(crossing, 'f').homeworldRevivalDeployment?.player, 'f');
|
|
const placed = applyAction(reload(crossing), 'f', choice(crossing, 'f'));
|
|
assert.equal(placed.homeworldRevivalReturn!.stage, 'complete');
|
|
stable(placed);
|
|
}
|
|
let t = homeworldRevivalFixture({ tleilaxu: true });
|
|
positionRevivalForces(t, 't', { native: 8, tanks: 5 });
|
|
t = enterHomeworldRevival(t);
|
|
const crossing = applyAction(reload(t), 't', { type: 'revive', amount: 1, elite: 0 });
|
|
assert.notEqual(crossing.homeworldRevivalReturn ?? null, null);
|
|
assert.equal(viewGame(crossing, 't').homeworldRevivalDeployment?.player, 't');
|
|
stable(applyAction(reload(crossing), 't', choice(crossing, 't')));
|
|
const card = holdRevivalCard(t, 't', 'ghola');
|
|
const done = applyAction(t, 't', { type: 'card', card, amount: 2, elite: 0 });
|
|
assert.equal(
|
|
done.homeworldRevivalReturn ?? null,
|
|
null,
|
|
'Tleilax Ghola is not ordinary free revival.',
|
|
);
|
|
stable(done);
|
|
});
|
|
|
|
void test('signed deployment rejects corrupted original groups, missing choices, stale events and foreign destinations immutably', () => {
|
|
const g = applyAction(enterHomeworldRevival(homeworldRevivalFixture()), 'f', {
|
|
type: 'revive',
|
|
amount: 2,
|
|
elite: 2,
|
|
});
|
|
const action = choice(g, 'f');
|
|
reject(g, 'e', action);
|
|
reject(g, 'f', { ...action, event: 'old-event' });
|
|
reject(g, 'f', { ...action, destination: 'homeworld:emperor' });
|
|
reject(g, 'f', { ...action, decline: true });
|
|
for (const mutate of [
|
|
(bad: Game) => {
|
|
bad.homeworldRevivalReturn!.group.elite = 1;
|
|
},
|
|
(bad: Game) => {
|
|
bad.homeworldRevivalReturn!.quote.elite = 1;
|
|
},
|
|
(bad: Game) => {
|
|
bad.homeworldRevivalReturn!.player = 'e';
|
|
},
|
|
(bad: Game) => {
|
|
bad.homeworldRevivalReturn!.signature = 'forged';
|
|
},
|
|
(bad: Game) => {
|
|
delete bad.homeworldRevivalReturn;
|
|
},
|
|
(bad: Game) => {
|
|
bad.decision = null;
|
|
},
|
|
]) {
|
|
const bad = reload(g);
|
|
mutate(bad);
|
|
const before = structuredClone(bad);
|
|
assert.throws(() => viewGame(bad, 'f'), /revival|returned group/i);
|
|
assert.throws(() => normalizeAutomaticGame(bad), /revival|returned group/i);
|
|
reject(bad, 'f', action, /revival|returned group/i);
|
|
assert.deepEqual(bad, before);
|
|
}
|
|
stable(g);
|
|
});
|
|
|
|
void test('real deployment projections expose destinations only to their owner and every bot profile can settle them legally', () => {
|
|
const original = applyAction(
|
|
enterHomeworldRevival(homeworldRevivalFixture()),
|
|
'f',
|
|
{ type: 'revive', amount: 2, elite: 2 },
|
|
);
|
|
const observer = viewGame(original, 'e');
|
|
assert.deepEqual(observer.homeworldRevivalDeployment?.destinations ?? [], []);
|
|
assert.deepEqual(homeworldRevivalDeploymentActions(observer), []);
|
|
assert.equal(observer.decision?.kind, 'homeworldRevivalDeployment');
|
|
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
|
let done = reload(original), choices = 0;
|
|
while (done.homeworldRevivalReturn!.stage !== 'complete' && choices < 3) {
|
|
const view = viewGame(reload(done), 'f');
|
|
view.players.find((player) => player.id === 'f')!.bot = profile;
|
|
const proposed = homeworldRevivalDeploymentActions(view);
|
|
assert.ok(proposed.length > 0);
|
|
assert.deepEqual(botActions(view), proposed);
|
|
done = applyAction(reload(done), 'f', proposed[0]);
|
|
choices++;
|
|
}
|
|
assert.equal(done.homeworldRevivalReturn!.stage, 'complete');
|
|
assert.ok(choices > 0 && choices <= 2);
|
|
assert.equal(viewGame(done, 'f').homeworldRevivalDeployment, null);
|
|
stable(done);
|
|
}
|
|
});
|
|
|
|
void test('every bot profile uses legal returns across the low-to-high crossing and the shared block permits a leader return', () => {
|
|
for (const advanced of [false, true]) {
|
|
let g = homeworldRevivalFixture({ advanced });
|
|
positionRevivalForces(g, 'f', { native: 2, tanks: 3, eliteTanks: 3 });
|
|
g = enterHomeworldRevival(g);
|
|
const ghola = holdRevivalCard(g, 'f', 'ghola');
|
|
const ownView = viewGame(g, 'f');
|
|
// A return that itself reaches the high side is now legal under the
|
|
// provisional post-return population policy.
|
|
for (const action of [
|
|
{ type: 'revive', amount: 1, elite: 1 },
|
|
{ type: 'card', card: ghola, amount: 1, elite: 1 },
|
|
{ type: 'card', card: ghola, amount: 1 },
|
|
] as Action[])
|
|
assert.equal(
|
|
homeworldRevivalActionBlock(ownView, action),
|
|
null,
|
|
JSON.stringify(action),
|
|
);
|
|
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
|
const view = viewGame(reload(g), 'f');
|
|
view.players.find((player) => player.id === 'f')!.bot = profile;
|
|
const actions = botActions(view);
|
|
assert.ok(
|
|
actions.length,
|
|
`${profile} must retain a legal way to advance.`,
|
|
);
|
|
for (const action of actions) {
|
|
assert.equal(homeworldRevivalActionBlock(view, action), null);
|
|
const next = applyAction(reload(g), 'f', action);
|
|
inventory(next);
|
|
}
|
|
}
|
|
const leader = p(g, 'f').leaders[0];
|
|
leader.dead = true;
|
|
const leaderAction = { type: 'card', card: ghola, leader: leader.id };
|
|
assert.equal(
|
|
homeworldRevivalActionBlock(viewGame(g, 'f'), leaderAction),
|
|
null,
|
|
);
|
|
const done = applyAction(g, 'f', leaderAction);
|
|
assert.equal(
|
|
p(done, 'f').leaders.find((candidate) => candidate.id === leader.id)!
|
|
.dead,
|
|
false,
|
|
);
|
|
assert.equal(p(done, 'f').reserves, 2);
|
|
assert.equal(p(done, 'f').tanks, 3);
|
|
assert.equal(done.homeworldRevivalReturn ?? null, null);
|
|
assert.equal(done.discard.filter((card) => card.id === ghola).length, 1);
|
|
stable(done);
|
|
}
|
|
});
|