Files
Dune/tests/homeworld-revival-deployment-controls.test.ts
T

419 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { HomeworldRevivalDeployment, HomeworldRevivalDeploymentButtons } from '../components/homeworld-revival-deployment';
import { baseDeck } from '../game/cards';
import {
createGame,
joinGame,
newPlayer,
viewGame,
type GameView,
} from '../game/engine';
import {
homeworldRevivalDeploymentActions,
homeworldRevivalDeploymentCanAct,
homeworldRevivalDeploymentChoice,
homeworldRevivalDeploymentQuantities,
homeworldRevivalActionBlock,
} from '../game/homeworld-revival-deployment-options';
// Projected decision fixtures isolate the controls and shared bot policy.
// Engine and recovery suites separately verify revival deposits and custody.
function fixture(kind: 'fedaykin' | 'tleilax' = 'fedaykin'): GameView {
const game = createGame(
'REVIVALCONTROLS',
newPlayer(
'f',
'Reviving player',
kind === 'fedaykin' ? 'fremen' : 'tleilaxu',
),
false,
['ix'],
);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
game.status = 'playing';
game.phase = 4;
game.active = 'a';
game.players[0].forces = { 'arrakeen:9': 1 };
const view = viewGame(game, 'f');
view.decision = {
kind: 'homeworldRevivalDeployment',
player: 'f',
event: 'revival-deployment-1',
};
view.homeworldRevivalDeployment = {
event: 'revival-deployment-1',
player: 'f',
kind,
normal: kind === 'tleilax' ? 3 : 0,
elite: kind === 'fedaykin' ? 2 : 0,
blocked: null,
destinations: [
{
id: 'hagga_basin:8',
name: 'Hagga Basin · sector 8',
territory: 'hagga_basin',
sector: 8,
blocked: null,
},
{
id: 'carthag:10',
name: 'Carthag · sector 10',
territory: 'carthag',
sector: 10,
blocked: 'The territory already contains two other factions.',
},
{
id: 'arrakeen:9',
name: 'Arrakeen · sector 9',
territory: 'arrakeen',
sector: 9,
blocked: null,
},
],
};
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(
createElement(HomeworldRevivalDeployment, { game, busy, act() {} }),
);
}
function allocated(remaining = 3, placed = 0): GameView {
const view = fixture(), offer = view.homeworldRevivalDeployment!;
offer.elite = remaining;
offer.allocation = { total: remaining + placed, remaining, placed, kept: 0 };
return view;
}
type ButtonProps = { children?: ReactNode; [key: string]: unknown };
function elements(node: ReactNode): ReactElement<ButtonProps>[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ButtonProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function deploymentButton(props: Parameters<typeof HomeworldRevivalDeploymentButtons>[0], action: 'place' | 'reserves') {
const button = elements(HomeworldRevivalDeploymentButtons(props)).find(element => element.props['data-revival-deployment-action'] === action);
assert.ok(button); return button;
}
const click = (button: ReactElement<ButtonProps>) => (button.props.onClick as () => void)();
void test('revival controls commit the entire eligible typed group to one current destination', () => {
for (const kind of ['fedaykin', 'tleilax'] as const) {
const view = fixture(kind);
const offer = view.homeworldRevivalDeployment!;
const before = structuredClone(view);
assert.deepEqual(homeworldRevivalDeploymentQuantities(view), [offer.normal + offer.elite]);
assert.equal(homeworldRevivalDeploymentChoice(view, 'arrakeen:9', 1).action, null);
assert.deepEqual(homeworldRevivalDeploymentChoice(view, 'arrakeen:9'), {
blocked: null,
action: {
type: 'decision',
event: offer.event,
destination: 'arrakeen:9',
amount: offer.normal + offer.elite,
},
});
assert.deepEqual(view, before);
const html = markup(view);
assert.match(
html,
new RegExp(`${offer.normal} ordinary · ${offer.elite} starred`),
);
assert.match(
html,
new RegExp(`Place ${offer.normal + offer.elite} revived forces`),
);
assert.doesNotMatch(html, /type="number"|type="range"/);
assert.match(html, /Leave in reserves/);
}
const advanced = fixture(); advanced.advanced = true; advanced.homeworldRevivalDeployment!.elite = 1;
assert.deepEqual(homeworldRevivalDeploymentQuantities(advanced), [1]);
assert.equal(homeworldRevivalDeploymentChoice(advanced, 'arrakeen:9').action!.amount, 1);
assert.doesNotMatch(markup(advanced), /Starred forces to place now|Provisional per-star interpretation/);
});
void test('new Basic Southern allocation defaults to one and enumerates native destinations and quantities for every profile', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = allocated(), offer = view.homeworldRevivalDeployment!; view.players[0].bot = profile;
for (const key of ['hand', 'spice', 'traitors', 'forces'])
Object.defineProperty(view.players[1], key, { get() { throw Error(`No foreign ${key} allocation inspection`); } });
assert.deepEqual(homeworldRevivalDeploymentQuantities(view), [1, 2, 3]);
assert.deepEqual(homeworldRevivalDeploymentChoice(view, 'hagga_basin:8').action,
{ type: 'decision', event: offer.event, destination: 'hagga_basin:8', amount: 1 });
const actions = homeworldRevivalDeploymentActions(view);
assert.deepEqual(actions.slice(0, 3).map(action => [action.destination, action.amount]),
[['arrakeen:9', 1], ['arrakeen:9', 2], ['arrakeen:9', 3]]);
assert.deepEqual(actions.slice(3, 6).map(action => [action.destination, action.amount]),
[['hagga_basin:8', 1], ['hagga_basin:8', 2], ['hagga_basin:8', 3]]);
assert.deepEqual(actions[6], { type: 'decision', event: offer.event, decline: true });
const html = markup(view); assert.match(html, /3 starred remaining of 3/);
assert.match(html, /0 placed · 0 kept in reserves/); assert.match(html, /eligible batch is fixed by this one original revival/);
assert.match(html, /Provisional per-star interpretation/); assert.match(html, /no publisher clarification/);
assert.match(html, /Starred forces to place now/); assert.match(html, /<option value="1" selected="">1<\/option>/);
assert.match(html, /Place 1 revived force/); assert.match(html, /Leave all remaining in reserves/);
}
});
void test('allocation metadata and amount bounds fail closed instead of reverting to a whole-group action', () => {
for (const allocation of [null,
{ total: 3, remaining: 2, placed: 0, kept: 0 },
{ total: 3, remaining: 3, placed: -1, kept: 1 },
{ total: 3, remaining: 1.5, placed: 1.5, kept: 0 },
{ total: 21, remaining: 3, placed: 18, kept: 0 },
{ total: 3, remaining: 3, placed: 0, kept: 0, quantity: 1 },
]) {
const view = allocated(); Object.assign(view.homeworldRevivalDeployment!, { allocation });
assert.deepEqual(homeworldRevivalDeploymentQuantities(view), []);
assert.match(homeworldRevivalDeploymentChoice(view, 'arrakeen:9').blocked!, /allocation is no longer available/);
assert.deepEqual(homeworldRevivalDeploymentActions(view), [{ type: 'decision', event: 'revival-deployment-1', decline: true }]);
}
for (const amount of [0, -1, 1.5, NaN, Infinity, 4])
assert.equal(homeworldRevivalDeploymentChoice(allocated(), 'arrakeen:9', amount).action, null);
for (const change of [
(view: GameView) => { view.advanced = true; },
(view: GameView) => { view.homeworldRevivalDeployment!.kind = 'tleilax'; },
(view: GameView) => { view.homeworldRevivalDeployment!.normal = 1; },
]) {
const view = allocated(); change(view); assert.deepEqual(homeworldRevivalDeploymentQuantities(view), []);
assert.equal(homeworldRevivalDeploymentChoice(view, 'arrakeen:9', 1).action, null);
}
});
void test('allocation callbacks submit only the selected native amount or all-remaining decline and honor busy and changed choices', () => {
const game = allocated(), sent: unknown[] = [];
const props = { game, busy: false, destination: 'arrakeen:9', amount: 2, act(action: unknown) { sent.push(action); } };
const place = deploymentButton(props, 'place'), reserves = deploymentButton(props, 'reserves');
assert.equal(place.props.disabled, false); click(place); click(reserves);
assert.deepEqual(sent, [{ type: 'decision', event: 'revival-deployment-1', destination: 'arrakeen:9', amount: 2 },
{ type: 'decision', event: 'revival-deployment-1', decline: true }]);
props.busy = true; click(place); click(reserves); assert.equal(sent.length, 2);
assert.equal(deploymentButton(props, 'place').props.disabled, true);
props.busy = false; game.homeworldRevivalDeployment!.destinations[2].blocked = 'The current arrival must finish.';
click(place); click(reserves); assert.equal(sent.length, 2);
assert.match(markup(game, true), /<select[^>]*aria-label="Starred forces to place now"[^>]*disabled/);
});
void test('each fresh child event resets the quantity and rejects old callbacks until original arrival children finish', () => {
const game = allocated(), sent: unknown[] = [];
const props = { game, busy: false, destination: 'arrakeen:9', amount: 2, act(action: unknown) { sent.push(action); } };
const oldKey = HomeworldRevivalDeployment(props)!.key;
const place = deploymentButton(props, 'place'), reserves = deploymentButton(props, 'reserves');
game.response = { kind: 'revivalIncome', owner: 'a', passed: [] };
assert.deepEqual(homeworldRevivalDeploymentQuantities(game), []); click(place); click(reserves); assert.equal(sent.length, 0);
game.response = null;
game.homeworldRevivalDeployment = { ...game.homeworldRevivalDeployment!, event: 'revival-deployment-2', elite: 1,
allocation: { total: 3, remaining: 1, placed: 2, kept: 0 } };
assert.equal(homeworldRevivalDeploymentCanAct(game), false);
game.decision = { kind: 'homeworldRevivalDeployment', player: 'f', event: 'revival-deployment-2' };
click(place); click(reserves); assert.equal(sent.length, 0);
assert.notEqual(HomeworldRevivalDeployment(props)!.key, oldKey);
assert.deepEqual(homeworldRevivalDeploymentQuantities(game), [1]);
const html = markup(game); assert.match(html, /1 starred remaining of 3 · 2 placed/);
assert.match(html, /<option value="1" selected="">1<\/option>/); assert.match(html, /Place 1 revived force/);
assert.deepEqual(homeworldRevivalDeploymentChoice(game, 'hagga_basin:8').action,
{ type: 'decision', event: 'revival-deployment-2', destination: 'hagga_basin:8', amount: 1 });
});
void test('current projected restrictions invalidate stale selections and preserve a decline path', () => {
const view = fixture();
assert.equal(
homeworldRevivalDeploymentChoice(view, 'deleted-destination').action,
null,
);
assert.match(
homeworldRevivalDeploymentChoice(view, 'carthag:10').blocked!,
/two other factions/,
);
view.homeworldRevivalDeployment!.destinations[2].blocked =
'Awaiting the entry ruling.';
assert.equal(
homeworldRevivalDeploymentChoice(view, 'arrakeen:9').action,
null,
);
view.homeworldRevivalDeployment!.blocked =
'Awaiting the revival timing ruling.';
const before = structuredClone(view);
assert.deepEqual(homeworldRevivalDeploymentActions(view), [
{ type: 'decision', event: 'revival-deployment-1', decline: true },
]);
const html = markup(view);
assert.match(html, /Awaiting the revival timing ruling/);
assert.match(
html,
/<button[^>]* disabled=""[^>]*>Place 2 revived forces<\/button>/,
);
assert.match(
html,
/<button(?![^>]* disabled="")[^>]*>Leave in reserves<\/button>/,
);
assert.deepEqual(view, before);
view.homeworldRevivalDeployment!.blocked = null;
view.homeworldRevivalDeployment!.destinations = [];
assert.equal(homeworldRevivalDeploymentActions(view)[0].decline, true);
assert.match(markup(view), /No legal revival destination/);
});
void test('deployment policy uses own public force positions for all profiles without private opponent access', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = fixture('tleilax');
view.players[0].bot = profile;
for (const key of ['hand', 'spice', 'traitors', 'forces'])
Object.defineProperty(view.players[1], key, {
get() {
throw new Error(`Read opponent ${key}`);
},
});
const offer = view.homeworldRevivalDeployment!;
const before = JSON.stringify(offer);
assert.deepEqual(homeworldRevivalDeploymentActions(view), [
{
type: 'decision',
event: offer.event,
destination: 'arrakeen:9',
amount: 3,
},
]);
offer.destinations[2].blocked = 'Entry unavailable.';
assert.equal(
homeworldRevivalDeploymentActions(view)[0].destination,
'hagga_basin:8',
);
offer.destinations[2].blocked = null;
assert.equal(JSON.stringify(offer), before);
view.players[0].forces = {};
offer.destinations = [
{
id: 'homeworld:emperor',
homeworld: 'homeworld:emperor',
name: 'Kaitain',
blocked: null,
},
];
assert.equal(
homeworldRevivalDeploymentActions(view)[0].destination,
'homeworld:emperor',
);
}
});
void test('owner, decision and interrupt gates suppress actions and hide observer deployment details', () => {
const view = fixture();
assert.equal(homeworldRevivalDeploymentCanAct(view), true);
for (const patch of [
{ me: 'a' },
{ status: 'finished' },
{ decision: null },
{
decision: {
kind: 'homeworldRevivalDeployment',
player: 'f',
event: 'old-event',
},
},
{ decision: { kind: 'homeworldRevivalDeployment', player: 'a' } },
{ decision: { kind: 'guildShipment', player: 'f' } },
{ response: { kind: 'revivalIncome', owner: 'a', passed: [] } },
{ truthtrance: { stage: 'answer' } },
{ phaseOpening: { kind: 'ix' } },
{ automaticContinuationPending: true },
]) {
const blocked = { ...view, ...patch } as GameView;
assert.equal(homeworldRevivalDeploymentCanAct(blocked), false);
assert.deepEqual(homeworldRevivalDeploymentActions(blocked), []);
assert.equal(
homeworldRevivalDeploymentChoice(blocked, 'arrakeen:9').action,
null,
);
}
assert.equal(markup({ ...view, me: 'a' }), '');
assert.equal(markup({ ...view, homeworldRevivalDeployment: null }), '');
assert.equal(markup({ ...view, decision: null }), '');
const html = markup(view, true);
assert.equal((html.match(/<button[^>]* disabled=""/g) ?? []).length, 2);
assert.match(html, /<select[^>]*disabled/);
});
void test('invalid revived counts never create a placement action or bot retry loop', () => {
for (const [normal, elite] of [
[0, 0],
[-1, 2],
[0, 0.5],
[21, 0],
[NaN, 2],
]) {
const view = fixture();
Object.assign(view.homeworldRevivalDeployment!, { normal, elite });
assert.match(
homeworldRevivalDeploymentChoice(view, 'arrakeen:9').blocked!,
/group is no longer available/,
);
assert.deepEqual(homeworldRevivalDeploymentActions(view), [
{ type: 'decision', event: 'revival-deployment-1', decline: true },
]);
}
});
void test('the shared action block resolves default Ghola stars and matches only its owned card, source and exact group', () => {
const view = fixture();
const own = view.players[0];
const ghola = baseDeck().find((card) => card.effect === 'ghola')!;
const karama = baseDeck().find((card) => card.effect === 'karama')!;
own.hand = [ghola, karama];
own.tanks = 5;
own.elites = { reserves: 0, tanks: 3, forces: {}, revived: 0 };
view.homeworldRevivalBlocks = [
{
source: 'normal',
amount: 5,
elite: 3,
reason: 'Ordinary return timing.',
},
{ source: 'ghola', amount: 5, elite: 3, reason: 'Ghola return timing.' },
{ source: 'ghola', amount: 3, elite: 1, reason: 'One-star Ghola timing.' },
];
const before = structuredClone(view);
assert.equal(
homeworldRevivalActionBlock(view, { type: 'card', card: ghola.id }),
'Ghola return timing.',
);
assert.equal(
homeworldRevivalActionBlock(view, {
type: 'card',
card: ghola.id,
amount: 3,
}),
'One-star Ghola timing.',
);
assert.equal(
homeworldRevivalActionBlock(view, { type: 'revive', amount: 5 }),
'Ordinary return timing.',
);
assert.equal(
homeworldRevivalActionBlock(view, {
type: 'card',
card: ghola.id,
amount: 3,
elite: 2,
}),
null,
);
for (const action of [
{ type: 'card', card: ghola.id, leader: 'dead-leader' },
{ type: 'revive', amount: 5, leader: 'dead-leader' },
{ type: 'card', card: karama.id, amount: 5, elite: 3 },
{ type: 'card', card: 'opponent-ghola', amount: 5, elite: 3 },
{ type: 'emperorRevival', amount: 5, elite: 3 },
])
assert.equal(homeworldRevivalActionBlock(view, action), null);
assert.deepEqual(view, before);
own.hand = [karama];
assert.equal(
homeworldRevivalActionBlock(view, { type: 'card', card: ghola.id }),
null,
);
});