205 lines
13 KiB
TypeScript
205 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
|
import { ownedTech } from '../game/tech-tokens';
|
|
import { validateLeaderSkills } from '../game/leader-skills';
|
|
import { advanceSkillsTechPaymentsToPhase } from './fixture-skills-tech-payments';
|
|
import {
|
|
createIxChoamSkillsTechFixture, initializeIxChoamSkillsTechSetup,
|
|
ixChoamSkillsTechPlayer as player, nextIxChoamSkillsTechStep, settleIxChoamSkillsTech,
|
|
} from './fixture-ix-choam-skills-tech';
|
|
|
|
function custody(game: Game): void {
|
|
validateLeaderSkills(game.leaderSkills!, game.players);
|
|
for (const p of game.players) {
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
|
if (p.elites) {
|
|
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
|
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
|
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
|
|
}
|
|
}
|
|
}
|
|
function reject(game: Game, actor: string, action: Action): void {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before, 'A rejected native action cannot mutate custody or money.');
|
|
}
|
|
|
|
for (const advanced of [false, true]) {
|
|
const rules = advanced ? 'Advanced' : 'Basic';
|
|
for (const family of ['ixians', 'choam'] as const) {
|
|
for (const smuggler of [true, false]) void test(`${rules} ${family}: real typed four-counter invoice ${smuggler ? 'includes the free companion' : 'declines it'} and pays only native Heighliners at phase end`, () => {
|
|
const fixture = createIxChoamSkillsTechFixture({ advanced, family, amount: 4, smuggler });
|
|
const before = fixture.game;
|
|
const payer = player(before, fixture.actor);
|
|
const tokenOwner = before.techTokens!.heighliners.owner!;
|
|
const paid = settleIxChoamSkillsTech(applyAction(before, fixture.actor, fixture.action));
|
|
const delivered = player(paid, fixture.actor);
|
|
assert.equal(delivered.spice, payer.spice - (smuggler ? 3 : 4));
|
|
assert.equal(delivered.reserves, payer.reserves - 4);
|
|
assert.equal(delivered.forces[fixture.key!], 4, 'Free means a delivered physical counter, not a price-only fiction.');
|
|
if (family === 'ixians') {
|
|
assert.equal(tokenOwner, fixture.owner, 'Original setup assigns printed Heighliners to Ixians.');
|
|
assert.equal(delivered.elites!.reserves, payer.elites!.reserves - 2);
|
|
assert.equal(delivered.elites!.forces[fixture.key!], 2);
|
|
}
|
|
for (const p of before.players.filter(p => p.id !== fixture.actor))
|
|
assert.equal(player(paid, p.id).spice, p.spice, 'Neither deferred store is spendable upon arrival.');
|
|
const techAmount = ownedTech(before.techTokens, tokenOwner).length;
|
|
assert.equal(paid.techTokens!.heighliners.spice, techAmount);
|
|
assert.equal(paid.techTokens!.axlotl.spice, before.techTokens!.axlotl.spice);
|
|
const bankerAmount = smuggler ? 0 : 1;
|
|
assert.deepEqual(viewGame(paid, fixture.banker!).spiceBankerIncome!.deferred,
|
|
bankerAmount ? [{ owner: fixture.banker, amount: bankerAmount }] : []);
|
|
const sources = structuredClone(paid.spiceBankerIncome!.sources);
|
|
const end = advanceSkillsTechPaymentsToPhase(paid, 6);
|
|
// Empty Battle may auto-skip into native Collection. Quote that separately,
|
|
// rather than treating stronghold income as a discounted shipment fee.
|
|
const expected = end.phase === 7
|
|
? Object.fromEntries(quoteSpiceCollection(paid).receipts.map(r => [r.player, r.balance]))
|
|
: Object.fromEntries(paid.players.map(p => [p.id, p.spice]));
|
|
expected[tokenOwner] += techAmount;
|
|
assert.deepEqual(Object.fromEntries(end.players.map(p => [p.id, p.spice])), expected);
|
|
assert.equal(end.techTokens!.heighliners.spice, 0);
|
|
assert.deepEqual(end.spiceBankerIncome!.sources, sources, 'Tech and Collection income are not Banker bank payments.');
|
|
const collection = advanceSkillsTechPaymentsToPhase(end, 7);
|
|
const mentat = advanceSkillsTechPaymentsToPhase(collection, 8);
|
|
for (const p of collection.players)
|
|
assert.equal(player(mentat, p.id).spice, p.spice + (p.id === fixture.banker ? bankerAmount : 0));
|
|
assert.deepEqual(viewGame(mentat, fixture.banker!).spiceBankerIncome!.deferred, []);
|
|
assert.deepEqual(mentat.spiceBankerIncome!.sources, sources);
|
|
reject(paid, fixture.actor, fixture.action);
|
|
custody(mentat);
|
|
});
|
|
|
|
for (const band of ['normal', 'skilled'] as const) void test(`${rules} ${family} ${band} Suk: native typed casualties rescue to board/reserves, remaining counters enter Tanks, and the original loser token follows victory`, () => {
|
|
const fixture = createIxChoamSkillsTechFixture({ advanced, family, kind: 'suk-battle', band });
|
|
const before = fixture.game;
|
|
const owner = player(before, fixture.owner);
|
|
const enemyToken = ownedTech(before.techTokens, fixture.opponent);
|
|
assert.equal(enemyToken.length, 1, 'Three original seats receive one original token each.');
|
|
let game = applyAction(before, fixture.owner, fixture.action);
|
|
game = applyAction(game, fixture.opponent, fixture.opponentPlan!);
|
|
game = settleIxChoamSkillsTech(game, true);
|
|
const pending = game;
|
|
const decision = game.decision?.kind === 'sukRescue' ? game.decision : null;
|
|
const losses = decision ? game.pendingSukRescue!.losses!
|
|
: family === 'ixians' ? { normal: 0, elite: 2 } : { normal: 4, elite: 0 };
|
|
assert.equal(player(game, fixture.owner).spice, owner.spice - fixture.cost,
|
|
'CHOAM own support earns no CHOAM payment income; Tech reward does not mint money.');
|
|
assert.equal(game.techTokens![enemyToken[0]].owner, fixture.opponent, 'Suk resolves before original Tech custody reward.');
|
|
if (family === 'ixians' && advanced)
|
|
assert.deepEqual(losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 1 });
|
|
if (!decision) {
|
|
assert.equal(band, 'normal');
|
|
assert.equal(game.lastBattleContext!.sukRescue!.completed, true, 'A uniquely determined normal rescue is automatic.');
|
|
}
|
|
const choice = decision?.options.findIndex(o => band === 'normal'
|
|
? o.normal === (family === 'ixians' ? 0 : 1) && o.elite === (family === 'ixians' ? 1 : 0) && !o.kept
|
|
: family === 'ixians' && !advanced
|
|
? o.normal === 0 && o.elite === 2 && o.kept?.kind === 'elite'
|
|
: o.normal === (family === 'ixians' ? 2 : 3) && o.elite === (family === 'ixians' ? 1 : 0) && o.kept?.kind === 'normal') ?? -1;
|
|
if (decision) assert.ok(choice >= 0);
|
|
const selected = decision ? decision.options[choice]
|
|
: { normal: family === 'ixians' ? 0 : 1, elite: family === 'ixians' ? 1 : 0, kept: null };
|
|
if (decision) {
|
|
reject(pending, fixture.opponent, { type: 'decision', event: decision.event, choice });
|
|
reject(pending, fixture.owner, { type: 'decision', event: decision.event, choice: decision.options.length });
|
|
game = applyAction(pending, fixture.owner, { type: 'decision', event: decision.event, choice });
|
|
}
|
|
const rescued = player(game, fixture.owner);
|
|
const kept = selected.kept ? 1 : 0;
|
|
assert.equal(rescued.reserves, owner.reserves + selected.normal + selected.elite - kept);
|
|
assert.equal(rescued.tanks, owner.tanks + losses.normal + losses.elite - selected.normal - selected.elite);
|
|
assert.equal(rescued.forces[fixture.key!], 8 - losses.normal - losses.elite + kept);
|
|
if (family === 'ixians') {
|
|
assert.equal(rescued.elites!.reserves, owner.elites!.reserves + selected.elite - (selected.kept?.kind === 'elite' ? 1 : 0));
|
|
assert.equal(rescued.elites!.tanks, owner.elites!.tanks + losses.elite - selected.elite);
|
|
assert.equal(rescued.elites!.forces[fixture.key!] ?? 0, 2 - losses.elite + (selected.kept?.kind === 'elite' ? 1 : 0));
|
|
}
|
|
custody(game);
|
|
game = settleIxChoamSkillsTech(game);
|
|
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
|
assert.equal(game.techTokens![enemyToken[0]].owner, fixture.owner);
|
|
assert.equal(ownedTech(game.techTokens, fixture.owner).length, 2);
|
|
assert.equal(ownedTech(game.techTokens, fixture.opponent).length, 0);
|
|
assert.equal(game.techTokens![enemyToken[0]].spice, 0, 'Original reward transfers custody, not a new income event.');
|
|
custody(game);
|
|
|
|
if (decision) for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(pending, fixture.owner);
|
|
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
|
|
const action = botActions(view)[0];
|
|
assert.ok(action, difficulty);
|
|
const continued = applyAction(pending, fixture.owner, action);
|
|
assert.equal(continued.pendingSukRescue, null);
|
|
assert.equal(player(continued, fixture.owner).spice, owner.spice - fixture.cost);
|
|
custody(continued);
|
|
}
|
|
});
|
|
}
|
|
|
|
void test(`${rules} CHOAM: real exhausted Emperor shipment makes next-turn charity eligible; only original Spice Production accrues, never Axlotl`, () => {
|
|
const fixture = createIxChoamSkillsTechFixture({ advanced, family: 'choam', kind: 'charity' });
|
|
const before = fixture.game;
|
|
assert.ok(fixture.actions.some(step => step.actor === fixture.actor && step.action.type === 'ship'
|
|
&& step.action.amount === 5), 'Eligibility comes from an actual original ten-spice desert invoice.');
|
|
const incomeBefore = fixture.beforeCharityIncome!;
|
|
const incomeAfter = fixture.afterCharityIncome!;
|
|
assert.equal(incomeBefore.response!.kind, 'choamCharity');
|
|
for (const p of incomeBefore.players) assert.equal(player(incomeAfter, p.id).spice,
|
|
p.spice + (p.id === fixture.owner ? 2 * incomeBefore.players.length : 0));
|
|
assert.deepEqual(incomeAfter.techTokens, incomeBefore.techTokens,
|
|
'Original CHOAM opening income is not a Charity claim or Tech trigger.');
|
|
assert.deepEqual(incomeAfter.spiceBankerIncome, incomeBefore.spiceBankerIncome,
|
|
'Opening income is not an actual four-spice bank payment.');
|
|
const productionOwner = before.techTokens!.production.owner!;
|
|
const after = applyAction(before, fixture.actor, fixture.action);
|
|
assert.equal(player(after, fixture.actor).spice, 2);
|
|
assert.equal(player(after, fixture.owner).spice, player(before, fixture.owner).spice - fixture.amount);
|
|
assert.equal(after.techTokens!.production.spice, ownedTech(before.techTokens, productionOwner).length);
|
|
assert.deepEqual(after.techTokens!.axlotl, before.techTokens!.axlotl);
|
|
assert.deepEqual(after.spiceBankerIncome, before.spiceBankerIncome, 'Charity is CHOAM-funded income, not a skill bank payment.');
|
|
reject(after, fixture.actor, fixture.action);
|
|
const end = advanceSkillsTechPaymentsToPhase(after, 3);
|
|
for (const p of after.players) assert.equal(player(end, p.id).spice,
|
|
p.spice + (p.id === productionOwner ? ownedTech(before.techTokens, productionOwner).length : 0));
|
|
assert.equal(end.techTokens!.production.spice, 0);
|
|
assert.deepEqual(end.techTokens!.axlotl, before.techTokens!.axlotl);
|
|
custody(end);
|
|
});
|
|
}
|
|
|
|
void test('Advanced CHOAM Auditor remains unskilled in original Tech setup and an eligible Bene Gesserit charity claim is expressly excluded from Spice Production', () => {
|
|
const initial = createGame('AUTHCHOAMTECH', newPlayer('human-e', 'Human Emperor', 'emperor'), true, ['choam']);
|
|
joinGame(initial, newPlayer('human-c', 'Human CHOAM', 'choam'));
|
|
joinGame(initial, newPlayer('human-bg', 'Human BG', 'beneGesserit'));
|
|
const identities = initial.players.map(p => ({ id: p.id, name: p.name, faction: p.faction }));
|
|
let setup = initializeIxChoamSkillsTechSetup({ initial, family: 'choam', kind: 'charity' });
|
|
while (setup.setupStage === 'prediction') {
|
|
const next = nextIxChoamSkillsTechStep(setup);
|
|
setup = applyAction(setup, next.actor, next.action);
|
|
}
|
|
const offer = setup.leaderSkills!.offers['human-c'];
|
|
assert.ok(!viewGame(setup, 'human-c').leaderSkills!.eligibleLeaders.some(l => l.id === 'choam-auditor'));
|
|
reject(setup, 'human-c', { type: 'leaderSkill', event: offer.event, skill: 'smuggler', leader: 'choam-auditor' });
|
|
const fixture = createIxChoamSkillsTechFixture({ initial, family: 'choam', kind: 'charity' });
|
|
assert.equal(fixture.actor, 'human-e');
|
|
assert.equal(fixture.owner, 'human-c');
|
|
assert.deepEqual(fixture.game.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), identities);
|
|
assert.ok(!fixture.game.leaderSkills!.assignments.some(a => a.leader === 'choam-auditor'));
|
|
const before = fixture.game;
|
|
const claimed = settleIxChoamSkillsTech(applyAction(before, 'human-bg', { type: 'charity' }));
|
|
assert.equal(player(claimed, 'human-bg').spice, player(before, 'human-bg').spice + 2);
|
|
assert.equal(player(claimed, 'human-c').spice, player(before, 'human-c').spice - 2);
|
|
assert.deepEqual(claimed.techTokens!.production, before.techTokens!.production, 'Advanced BG income is not the original production trigger.');
|
|
assert.deepEqual(claimed.techTokens!.axlotl, before.techTokens!.axlotl);
|
|
const eligible = applyAction(claimed, fixture.actor, fixture.action);
|
|
assert.equal(eligible.techTokens!.production.spice, 1);
|
|
custody(eligible);
|
|
});
|