Files
Dune/tests/tleilaxu-ambassador-engine.test.ts

225 lines
7.2 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 { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { registerHooks } from 'node:module';
/** A genuine Ecaz arrival whose Tleilaxu token is assigned to an elite ally. */
function fixture() {
const g = createGame('TLEIAMBA', newPlayer('ecaz', 'Ecaz', 'ecaz'));
g.players.push(
newPlayer('entrant', 'Entrant', 'atreides'),
newPlayer('ally', 'Ally', 'emperor'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
order: ['entrant', 'ecaz', 'ally'],
active: 'entrant',
});
g.movementRemaining = [...g.order];
g.deck = baseDeck();
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.reserves = 20;
p.spice = 10;
p.traitors = [p.leaders[0].id];
}
const ecaz = g.players[0], ally = g.players[2];
ecaz.ally = ally.id;
ally.ally = ecaz.id;
ally.tanks = 3;
ally.reserves = 15;
ally.elites = { reserves: 2, tanks: 1, forces: {}, revived: 0 };
const state = createAmbassadors(() => 0);
const token = state.tokens.find((t) => t.effect === 'tleilaxu')!;
const cohortEffects = [
'tleilaxu',
...state.tokens
.map((t) => t.effect)
.filter((e) => e !== 'tleilaxu' && e !== 'ecaz'),
].slice(0, 5);
state.cohort = state.tokens
.filter((t) => cohortEffects.includes(t.effect))
.map((t) => t.id);
for (const other of state.tokens) {
other.zone =
other.effect === 'ecaz' || state.cohort.includes(other.id)
? 'supply'
: 'pool';
other.location = null;
}
token.zone = 'placed';
token.location = 'arrakeen';
validateAmbassadors(state);
g.ecazAmbassadors = state;
return g;
}
const ship = (g: Game) =>
applyAction(g, 'entrant', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
const trigger = (g: Game) => {
const event = g.pendingAmbassador!.event;
return applyAction(g, 'ecaz', {
type: 'decision',
event,
trigger: true,
beneficiary: 'ally',
choice: 'effect',
});
};
const counters = (g: Game, id: string) => {
const p = g.players.find((seat) => seat.id === id)!;
return {
reserves: p.reserves,
tanks: p.tanks,
eliteReserves: p.elites?.reserves ?? 0,
eliteTanks: p.elites?.tanks ?? 0,
eliteRevived: p.elites?.revived ?? 0,
spice: p.spice,
};
};
void test('the Tleilaxu Ambassador returns typed forces from the Tanks for free and spends the shared elite option', () => {
const offered = trigger(ship(fixture()));
assert.equal(offered.pendingAmbassador?.stage, 'revival');
assert.equal(offered.pendingAmbassador?.effect, 'tleilaxu');
assert.equal(offered.decision?.kind, 'ecazAmbassador');
assert.equal(offered.decision?.player, 'ally');
const view = viewGame(offered, 'ally').ambassadorEntry!;
assert.equal(view.revival?.maximum, 3);
assert.equal(view.revival?.eliteTanks, 1);
assert.equal(view.revival?.blocked, null);
assert.equal(viewGame(offered, 'ecaz').ambassadorEntry!.revival, null);
assert.equal(viewGame(offered, 'entrant').ambassadorEntry!.revival, null);
const before = counters(offered, 'ally');
const event = offered.pendingAmbassador!.event;
// A second elite return is rejected by the shared one-per-turn limit.
const spent = structuredClone(offered);
spent.players.find((p) => p.id === 'ally')!.elites!.revived = 1;
assert.throws(
() =>
applyAction(spent, 'ally', {
type: 'decision',
event,
forces: 1,
elite: 1,
}),
/Only one Fedaykin or Sardaukar may be revived per turn/,
);
assert.deepEqual(spent.players.find((p) => p.id === 'ally')!.tanks, 3);
for (const bad of [
{ forces: 0 },
{ forces: 5 },
{ forces: 2, elite: 1.5 },
]) {
const copy = structuredClone(offered);
assert.throws(() =>
applyAction(copy, 'ally', { type: 'decision', event, ...bad }),
);
assert.deepEqual(copy, offered);
}
const returned = applyAction(offered, 'ally', {
type: 'decision',
event,
forces: 2,
elite: 1,
});
assert.deepEqual(counters(returned, 'ally'), {
reserves: before.reserves + 2,
tanks: before.tanks - 2,
eliteReserves: before.eliteReserves + 1,
eliteTanks: before.eliteTanks - 1,
eliteRevived: 1,
spice: before.spice,
});
assert.equal(returned.pendingAmbassador, null);
assert.equal(
returned.ecazAmbassadors!.tokens.find((t) => t.effect === 'tleilaxu')!.zone,
'used',
);
assert.equal(
returned.log.some((line) =>
line.text.includes('Tleilaxu Ambassador') &&
line.text.includes('Ordinary revival allowances'),
),
true,
);
});
void test('the Tleilaxu Ambassador can return only ordinary forces and can return none at all', () => {
const offered = trigger(ship(fixture()));
const event = offered.pendingAmbassador!.event;
const ordinary = applyAction(offered, 'ally', {
type: 'decision',
event,
forces: 2,
elite: 0,
});
const ally = ordinary.players.find((p) => p.id === 'ally')!;
assert.equal(ally.tanks, 1);
assert.equal(ally.reserves, 17);
assert.equal(ally.elites!.tanks, 1);
assert.equal(ally.elites!.revived, 0);
assert.equal(ally.spice, 10);
const fresh = trigger(ship(fixture()));
const declined = applyAction(fresh, 'ally', {
type: 'decision',
event: fresh.pendingAmbassador!.event,
decline: true,
});
assert.equal(declined.pendingAmbassador, null);
const seat = declined.players.find((p) => p.id === 'ally')!;
assert.equal(seat.tanks, 3);
assert.equal(seat.reserves, 15);
});
void test('the entry control offers each legal count, the elite-aware return and an explicit decline', async () => {
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
context,
);
},
});
const { EcazEntry } = await import('../components/ecaz-entry');
aliases.deregister();
const offered = trigger(ship(fixture()));
const view = viewGame(offered, 'ally') as unknown as Parameters<typeof EcazEntry>[0]['game'];
const actions: unknown[] = [];
const markup = renderToStaticMarkup(
createElement(EcazEntry, {
game: view,
act: (action: unknown) => actions.push(action),
busy: false,
}),
);
for (const label of ['Return 1 force', 'Return 2 forces', 'Return 3 forces', 'Return no forces'])
assert.ok(markup.includes(label), label);
assert.ok(markup.includes('Tleilaxu Ambassador'));
const blocked = structuredClone(offered);
blocked.players.find((p) => p.id === 'ally')!.tanks = 0;
const blockedView = viewGame(blocked, 'ally') as unknown as Parameters<typeof EcazEntry>[0]['game'];
const blockedMarkup = renderToStaticMarkup(
createElement(EcazEntry, {
game: blockedView,
act: (action: unknown) => actions.push(action),
busy: false,
}),
);
assert.equal(blockedMarkup.includes('Return 1 force'), false);
assert.ok(blockedMarkup.includes('Return no forces'));
});