136 lines
7.2 KiB
TypeScript
136 lines
7.2 KiB
TypeScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { applyAction, viewGame } from '../game/engine';
|
||
import { botActions } from '../game/bots';
|
||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||
import { controlsLeader } from '../game/leader-control';
|
||
import { pinDeterministicRandom } from './deterministic-random';
|
||
import {
|
||
ecazOccupyDialChoices,
|
||
ecazOccupyLeadOptions,
|
||
ecazOccupyOwnProfile,
|
||
ecazOccupyPlanControl,
|
||
ecazOccupyPolicyActions,
|
||
} from '../game/ecaz-occupy-options';
|
||
import {
|
||
cancelEcazOccupy,
|
||
chooseEcazOccupyLead,
|
||
ecazOccupyFixture,
|
||
openEcazOccupyPlans,
|
||
} from './fixture-ecaz-occupy';
|
||
|
||
void test('Ecaz can choose either real plan owner; other seats cannot choose the lead', () => {
|
||
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
|
||
const own = viewGame(fixture.game, fixture.ecaz);
|
||
const choices = ecazOccupyLeadOptions(own);
|
||
assert.deepEqual(choices.map((choice) => choice.lead), [fixture.ecaz, fixture.ally]);
|
||
for (const choice of choices) {
|
||
assert.equal(choice.blocked, null);
|
||
const game = applyAction(fixture.game, fixture.ecaz, choice.action);
|
||
const selected = ecazOccupyOwnProfile(viewGame(game, choice.lead));
|
||
assert.equal(selected?.planOwner, choice.lead);
|
||
assert.equal(selected?.payer, choice.lead);
|
||
assert.equal(selected?.forceOwner, fixture.ally);
|
||
assert.equal(selected?.fixedEcazDial, 2);
|
||
assert.equal(ecazOccupyOwnProfile(viewGame(game, fixture.opponent)), null);
|
||
}
|
||
const outsider = viewGame(fixture.game, fixture.ally);
|
||
assert.ok(ecazOccupyLeadOptions(outsider).every((choice) => !!choice.blocked));
|
||
assert.deepEqual(ecazOccupyPolicyActions(outsider), []);
|
||
for (const choice of choices)
|
||
assert.throws(() => applyAction(fixture.game, fixture.ally, choice.action));
|
||
});
|
||
|
||
void test('free fixed Ecaz strength is mandatory and cannot borrow the other ally’s spice', () => {
|
||
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
|
||
const game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, 'ecaz'));
|
||
// Labelled Spice Bank budget staging: no allied credit or force property changes.
|
||
game.players.find((player) => player.id === fixture.ecaz)!.spice = 0;
|
||
game.players.find((player) => player.id === fixture.ally)!.spice = 30;
|
||
const view = viewGame(game, fixture.ecaz);
|
||
const choices = ecazOccupyDialChoices(view);
|
||
assert.ok(choices.some((choice) => choice.dial === 2 && choice.support === 0));
|
||
assert.ok(choices.some((choice) => choice.dial === 2.5 && choice.support === 0));
|
||
assert.ok(choices.every((choice) => choice.support === 0 && choice.dial >= 2));
|
||
assert.equal(ecazOccupyPlanControl(view, 2, 0)?.blocked, null);
|
||
assert.ok(ecazOccupyPlanControl(view, 1.5, 0)?.blocked);
|
||
assert.ok(ecazOccupyPlanControl(view, 3, 1)?.blocked);
|
||
assert.deepEqual(ecazOccupyDialChoices(viewGame(game, fixture.ally)), []);
|
||
const me = view.players.find((player) => player.id === view.me)!;
|
||
const leader = me.leaders.find((entry) => !entry.dead && controlsLeader(me, entry) &&
|
||
(!entry.usedAt || entry.usedAt === fixture.territory))!;
|
||
assert.ok(leader);
|
||
const next = applyAction(game, fixture.ecaz, {
|
||
type: 'battlePlan', dial: 2.5, support: 0, leader: leader.id,
|
||
weapon: null, defense: null, kwisatz: false,
|
||
});
|
||
assert.equal(next.battle?.plans[fixture.ecaz].dial, 2.5);
|
||
assert.equal(next.battle?.plans[fixture.ecaz].support, 0);
|
||
assert.equal(next.players.find((player) => player.id === fixture.ecaz)?.spice, 0);
|
||
assert.equal(next.players.find((player) => player.id === fixture.ally)?.spice, 30);
|
||
});
|
||
|
||
void test('native Karama keeps either selected lead but replaces the active combined dial with their own army', () => {
|
||
for (const lead of ['ecaz', 'ally'] as const) {
|
||
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
|
||
const game = openEcazOccupyPlans(cancelEcazOccupy(fixture, lead));
|
||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||
const view = viewGame(game, actor);
|
||
const control = ecazOccupyPlanControl(view, 0, 0)!;
|
||
assert.equal(control.profile.canceled, true);
|
||
assert.equal(control.profile.planOwner, actor);
|
||
assert.equal(control.profile.forceOwner, actor);
|
||
assert.equal(control.minimum, 0);
|
||
assert.equal(control.fixedEcazDial, 0);
|
||
assert.equal(control.maximum, lead === 'ecaz' ? 3 : 4);
|
||
assert.equal(control.blocked, null);
|
||
assert.ok(ecazOccupyDialChoices(view).some((choice) => choice.dial === 0 && choice.support === 0));
|
||
assert.ok(ecazOccupyPlanControl(view, control.maximum + 0.5, 0)?.blocked);
|
||
if (lead === 'ecaz') assert.ok(ecazOccupyPlanControl(view, 4, 3)?.blocked);
|
||
}
|
||
});
|
||
|
||
void test('typed Fremen and Emperor variable armies retain native legal increments and support', () => {
|
||
const fremen = ecazOccupyFixture({ allyFaction: 'fremen', ecazForces: 3 });
|
||
const fremenGame = openEcazOccupyPlans(chooseEcazOccupyLead(fremen, 'ecaz'));
|
||
const fremenView = viewGame(fremenGame, fremen.ecaz);
|
||
const free = ecazOccupyDialChoices(fremenView);
|
||
assert.ok(free.some((choice) => choice.variableDial === 6 && choice.dial === 8));
|
||
assert.ok(free.every((choice) => choice.support === 0 && Number.isInteger(choice.variableDial)));
|
||
assert.ok(ecazOccupyPlanControl(fremenView, 2.5, 0)?.blocked);
|
||
assert.ok(ecazOccupyPlanControl(fremenView, 3, 1)?.blocked);
|
||
const emperor = ecazOccupyFixture({ allyFaction: 'emperor', ecazForces: 3 });
|
||
const emperorGame = openEcazOccupyPlans(chooseEcazOccupyLead(emperor, 'ecaz'));
|
||
const emperorView = viewGame(emperorGame, emperor.ecaz);
|
||
const paid = ecazOccupyDialChoices(emperorView);
|
||
assert.ok(paid.some((choice) => choice.variableDial === 0.5 && choice.support === 0));
|
||
assert.ok(paid.some((choice) => choice.variableDial === 6 && choice.support === 4));
|
||
assert.equal(ecazOccupyPlanControl(emperorView, 8, 4)?.blocked, null);
|
||
});
|
||
|
||
void test('all four native policies finish Occupy planning through optional powers for either lead, active or canceled', (t) => {
|
||
pinDeterministicRandom(t, 8);
|
||
for (const lead of ['ecaz', 'ally'] as const) for (const canceled of [false, true]) {
|
||
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
|
||
const game = openEcazOccupyPlans(canceled
|
||
? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead));
|
||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||
for (const policy of DIFFICULTIES) {
|
||
let state = structuredClone(game);
|
||
for (let step = 0; step < 4; step++) {
|
||
const view = viewGame(state, actor);
|
||
view.players.find((player) => player.id === actor)!.bot = policy;
|
||
const actions = botActions(view);
|
||
const action = actions[0];
|
||
assert.ok(action, `${policy}/${lead}/${canceled}: planning must retain a legal continuation`);
|
||
if (action.type === 'battlePlan')
|
||
assert.equal(ecazOccupyPlanControl(view, Number(action.dial), Number(action.support ?? 0))?.blocked, null);
|
||
state = applyAction(state, actor, action);
|
||
if (viewGame(state, actor).battle?.submitted.includes(actor)) break;
|
||
}
|
||
assert.ok(viewGame(state, actor).battle?.submitted.includes(actor),
|
||
`${policy}/${lead}/${canceled}: optional powers must finish and the selected lead must seal a plan`);
|
||
}
|
||
}
|
||
});
|