Files
Dune/tests/ecaz-occupy-ix-choam-runtime.test.ts
T

238 lines
15 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { ecazOccupyTerritoryForces } from './fixture-ecaz-occupy';
import {
nativeIxOccupyCase, nativeChoamOccupyCase, nativeOccupyPlayer,
type NativeIxOccupyCase, type NativeChoamOccupyCase,
} from './fixture-ecaz-ix-choam';
function forces(game: Game, result: NativeIxOccupyCase | NativeChoamOccupyCase, owner: string): number {
return ecazOccupyTerritoryForces(game, owner, result.fixture.territory);
}
function cyborgs(game: Game, result: NativeIxOccupyCase): number {
return nativeOccupyPlayer(game, result.fixture.ally).elites?.forces[result.fixture.location] ?? 0;
}
function conservedIx(game: Game, result: NativeIxOccupyCase): void {
const ix = nativeOccupyPlayer(game, result.fixture.ally);
assert.equal(ix.reserves + ix.tanks + Object.values(ix.forces).reduce((sum, count) => sum + count, 0), 20);
assert.ok(ix.elites);
assert.equal(ix.elites.reserves + ix.elites.tanks +
Object.values(ix.elites.forces).reduce((sum, count) => sum + count, 0), 7);
}
function sameEvent(result: NativeIxOccupyCase): void {
const event = result.before.battle?.ecazOccupy?.event;
assert.ok(event);
assert.equal(result.afterLosses.lastBattleContext?.event, event,
'Typed physical casualties belong to the original Occupy battle.');
assert.equal(result.game.lastBattleContext?.event, event,
'Ix substitution and selected-lead cleanup must not create a second battle.');
}
for (const lead of ['ecaz', 'ally'] as const)
for (const commitment of ['funded', 'half'] as const)
void test(`Native mixed Ix ${commitment} commitment with ${lead} lead settles typed losses, Suboids and lead cards`, () => {
const result = nativeIxOccupyCase({ lead, commitment });
const { fixture, actor, before, afterReveal, afterLosses, afterSubstitution, game } = result;
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
const expectedLost = commitment === 'funded' ? 4 : 5;
const expectedCyborgsLost = commitment === 'funded' ? 2 : 1;
const profile = viewGame(before, actor).battle?.ecazOccupy?.profile;
assert.ok(profile);
assert.equal(profile.forceOwner, fixture.ally);
assert.equal(profile.payer, actor);
assert.equal(profile.forces.normalFixedHalf, true, 'Actual Suboids stay half-strength regardless of selected lead.');
assert.equal(profile.maxSupport, 2, 'Only two actual Cyborgs can receive paid support.');
assert.equal(profile.fixedEcazDial, 3);
assert.ok(afterReveal.decision?.kind === 'battleLosses');
assert.equal(afterReveal.decision.player, actor);
assert.equal(afterReveal.decision.forceOwner, fixture.ally);
assert.ok(afterReveal.decision.options.some(loss => loss.normal === (commitment === 'funded' ? 2 : 4) &&
loss.elite === expectedCyborgsLost), 'The selected dial has a real physical typed winner allocation.');
assert.equal(forces(afterLosses, result, fixture.ally), 8 - expectedLost);
assert.equal(cyborgs(afterLosses, result), 2 - expectedCyborgsLost);
assert.equal(nativeOccupyPlayer(afterLosses, fixture.ally).elites!.tanks -
nativeOccupyPlayer(before, fixture.ally).elites!.tanks, expectedCyborgsLost);
assert.ok(afterLosses.decision?.kind === 'ixSubstitution');
assert.equal(afterLosses.decision.player, fixture.ally);
assert.equal(afterLosses.decision.losses[fixture.location], expectedCyborgsLost);
assert.equal(forces(afterSubstitution, result, fixture.ally), 8 - expectedLost,
'A surviving Suboid is exchanged, not an additional Cyborg created.');
assert.equal(cyborgs(afterSubstitution, result), 2);
assert.equal(nativeOccupyPlayer(afterSubstitution, fixture.ally).elites!.tanks,
nativeOccupyPlayer(before, fixture.ally).elites!.tanks);
assert.equal(nativeOccupyPlayer(afterSubstitution, fixture.ally).tanks -
nativeOccupyPlayer(before, fixture.ally).tanks, expectedLost);
assert.ok(afterSubstitution.decision?.kind === 'battleCards');
assert.equal(afterSubstitution.decision.player, actor,
'Native Ix substitution resumes the selected leader/card owner, not the force owner.');
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(forces(game, result, fixture.ecaz), 2);
assert.equal(forces(game, result, fixture.opponent), 0);
assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice,
commitment === 'funded' ? 1 : 0, 'Suboids and fixed Ecaz contribute no paid support.');
assert.equal(nativeOccupyPlayer(before, nonLead).spice, nativeOccupyPlayer(game, nonLead).spice);
assert.deepEqual(nativeOccupyPlayer(game, nonLead).hand, nativeOccupyPlayer(before, nonLead).hand);
assert.ok(game.discard.some(card => card.id === result.shield));
assert.ok(!nativeOccupyPlayer(game, actor).hand.some(card => card.id === result.shield));
sameEvent(result);
for (const checkpoint of [before, afterLosses, afterSubstitution, game]) conservedIx(checkpoint, result);
assert.ok(game.phase >= 7);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Native Ix zero variable commitment with ${lead} lead still pays the mandatory free Ecaz losses`, () => {
const result = nativeIxOccupyCase({ lead, commitment: 'zero' });
const { fixture, actor, before, game } = result;
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(forces(game, result, fixture.ecaz), 2);
assert.equal(forces(game, result, fixture.ally), 8);
assert.equal(cyborgs(game, result), 2);
assert.equal(nativeOccupyPlayer(game, fixture.ally).tanks, nativeOccupyPlayer(before, fixture.ally).tanks);
assert.equal(nativeOccupyPlayer(game, actor).spice, nativeOccupyPlayer(before, actor).spice);
assert.notEqual(result.afterLosses.decision?.kind, 'ixSubstitution');
conservedIx(game, result);
});
void test('Native Karama cancellation with Ecaz lead uses only Ecaz own army and leaves mixed Ix untouched on a win', () => {
const result = nativeIxOccupyCase({ lead: 'ecaz', canceled: true });
const { fixture, before, game } = result;
const profile = viewGame(before, fixture.ecaz).battle?.ecazOccupy?.profile;
assert.ok(profile?.canceled);
assert.equal(profile.forceOwner, fixture.ecaz);
assert.equal(profile.fixedEcazDial, 0);
assert.equal(profile.forces.normalFixedHalf, false);
assert.equal(game.lastBattleContext?.winner, fixture.ecaz);
assert.equal(forces(game, result, fixture.ecaz), 3);
assert.equal(forces(game, result, fixture.ally), 8);
assert.equal(cyborgs(game, result), 2);
assert.equal(nativeOccupyPlayer(before, fixture.ecaz).spice - nativeOccupyPlayer(game, fixture.ecaz).spice, 2);
assert.equal(nativeOccupyPlayer(game, fixture.ally).tanks, nativeOccupyPlayer(before, fixture.ally).tanks);
assert.equal(nativeOccupyPlayer(game, fixture.ally).elites!.tanks, nativeOccupyPlayer(before, fixture.ally).elites!.tanks);
assert.deepEqual(nativeOccupyPlayer(game, fixture.ally).hand, nativeOccupyPlayer(before, fixture.ally).hand);
assert.notEqual(result.afterLosses.decision?.kind, 'ixSubstitution');
sameEvent(result);
conservedIx(game, result);
});
void test('Native Karama cancellation with Ix lead uses Ix own typed army then returns to Ix cards without consuming Ecaz', () => {
const result = nativeIxOccupyCase({ lead: 'ally', canceled: true });
const { fixture, before, afterLosses, afterSubstitution, game } = result;
const profile = viewGame(before, fixture.ally).battle?.ecazOccupy?.profile;
assert.ok(profile?.canceled);
assert.equal(profile.forceOwner, fixture.ally);
assert.equal(profile.fixedEcazDial, 0);
assert.equal(game.lastBattleContext?.winner, fixture.ally);
assert.equal(forces(game, result, fixture.ecaz), 5);
assert.equal(forces(game, result, fixture.ally), 4);
assert.ok(afterLosses.decision?.kind === 'ixSubstitution');
assert.equal(afterLosses.decision.player, fixture.ally);
assert.equal(cyborgs(afterLosses, result), 0);
assert.equal(cyborgs(afterSubstitution, result), 2);
assert.ok(afterSubstitution.decision?.kind === 'battleCards');
assert.equal(afterSubstitution.decision.player, fixture.ally);
assert.equal(nativeOccupyPlayer(before, fixture.ally).spice - nativeOccupyPlayer(game, fixture.ally).spice, 1);
assert.deepEqual(nativeOccupyPlayer(game, fixture.ecaz).hand, nativeOccupyPlayer(before, fixture.ecaz).hand);
assert.equal(nativeOccupyPlayer(game, fixture.ecaz).tanks, nativeOccupyPlayer(before, fixture.ecaz).tanks);
sameEvent(result);
conservedIx(game, result);
});
void test('All four native minimal policies can continue after Ecaz-led Ix substitution and selected-card cleanup', () => {
const { actor, game } = nativeIxOccupyCase({ lead: 'ecaz' });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, actor);
const player = view.players.find(p => p.id === actor);
assert.ok(player);
player.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'ready');
assert.ok(action, `${difficulty} must be able to continue the actual post-Ix phase.`);
let next = applyAction(game, actor, action);
while (next.phaseOpening) {
const pending = next.players.find(p => !next.phaseOpening!.passed.includes(p.id));
assert.ok(pending);
next = applyAction(next, pending.id, { type: 'ready' });
}
assert.equal(next.phase, 7, 'The policy and native opening acknowledgments reach actual Collection.');
assert.equal(next.phaseOpening, null);
}
});
for (const relationship of ['coside', 'opposing'] as const)
for (const lead of ['ecaz', 'ally'] as const)
void test(`Native ${relationship} CHOAM with ${lead} lead receives only floor halves of actual other-payer support`, () => {
const result = nativeChoamOccupyCase({ relationship, lead });
const { fixture, actor, choam, before, revealed, game } = result;
const plan = revealed.battle?.plans[actor];
assert.ok(plan);
assert.equal(plan.support, 3);
assert.equal(plan.allyPayment ?? 0, 0);
assert.equal(plan.dial, 6, 'Three mandatory free Ecaz strength is not three additional support payments.');
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(game.lastBattleContext?.result, 'normal');
const actorIsChoam = actor === choam;
const opponentIsChoam = fixture.opponent === choam;
const income = actorIsChoam || opponentIsChoam ? 1 : 2;
assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(before, choam).spice,
income - (actorIsChoam || opponentIsChoam ? 3 : 0),
'Each actual other payer contributes floor(3/2); CHOAM own support goes wholly to the Bank.');
if (!actorIsChoam)
assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice, 3);
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
if (nonLead !== choam)
assert.equal(nativeOccupyPlayer(before, nonLead).spice, nativeOccupyPlayer(game, nonLead).spice);
assert.equal(forces(game, result, fixture.ecaz), 2);
assert.equal(forces(game, result, fixture.ally), 5);
assert.equal(forces(game, result, fixture.opponent), 0);
assert.ok(game.phase >= 7);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Native coside CHOAM fixed-only ${lead} lead has no bank payment or minted battle income`, () => {
const result = nativeChoamOccupyCase({ lead, support: 0, opponentSupport: 0 });
const { fixture, actor, choam, before, game } = result;
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(nativeOccupyPlayer(game, choam).spice, nativeOccupyPlayer(before, choam).spice);
assert.equal(nativeOccupyPlayer(game, fixture.ecaz).spice, nativeOccupyPlayer(before, fixture.ecaz).spice);
assert.equal(game.pendingChoamBattleIncome ?? null, null);
assert.equal(forces(game, result, fixture.ecaz), 2);
assert.equal(forces(game, result, fixture.ally), 8);
});
void test('Native CHOAM funding separates Ecaz own spice from actual prepaid donor spice and excludes donor share from income', () => {
const result = nativeChoamOccupyCase({ lead: 'ecaz', donorPayment: 2 });
const { fixture, actor, choam, beforeFunding, before, revealed, game } = result;
assert.equal(before.aid[choam]?.amount, 2, 'The native CHOAM offer must actually escrow the donor share.');
assert.equal(nativeOccupyPlayer(beforeFunding, choam).spice - nativeOccupyPlayer(before, choam).spice, 2);
assert.equal(revealed.battle?.plans[actor]?.allyPayment, 2);
assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice, 1);
assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(beforeFunding, choam).spice, -1,
'Two prepaid donor spice yield no CHOAM share; only floor(3/2) enemy income returns.');
assert.equal(game.lastBattleContext?.winner, fixture.ecaz);
assert.equal(forces(game, result, fixture.ecaz), 2);
assert.equal(forces(game, result, fixture.ally), 5);
});
for (const relationship of ['coside', 'opposing'] as const)
for (const lead of ['ecaz', 'ally'] as const)
void test(`Native ${relationship} CHOAM ${lead}-led traitor victory excludes all battle income and waives winner support`, () => {
const result = nativeChoamOccupyCase({ relationship, lead, traitor: true });
const { fixture, actor, choam, before, revealed, game } = result;
const opposingLeader = nativeOccupyPlayer(before, fixture.opponent).leaders.find(leader =>
leader.id === revealed.battle?.plans[fixture.opponent]?.leader);
assert.ok(opposingLeader);
const bounty = opposingLeader.strength;
assert.equal(game.lastBattleContext?.result, 'traitor');
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(nativeOccupyPlayer(game, actor).spice - nativeOccupyPlayer(before, actor).spice, bounty,
'The successful caller pays no winning support and retains the original leader bounty.');
assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(before, choam).spice,
choam === fixture.opponent ? -3 : choam === actor ? bounty : 0,
'No actual support payment generates CHOAM income during traitor combat; original winner bounty is separate.');
assert.equal(game.pendingChoamBattleIncome ?? null, null);
assert.equal(forces(game, result, fixture.ecaz), 5);
assert.equal(forces(game, result, fixture.ally), 8);
assert.equal(forces(game, result, fixture.opponent), 0);
});