Allow individual revived Fedaykin placement
This commit is contained in:
1 parent
6a0fba860d
commit
ff2826b980
25 files changed
+1301
-124
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { HomeworldRevivalDeployment } from '../components/homeworld-revival-deployment';
|
||||
import { HomeworldRevivalDeployment, HomeworldRevivalDeploymentButtons } from '../components/homeworld-revival-deployment';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import {
|
||||
createGame,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
homeworldRevivalDeploymentActions,
|
||||
homeworldRevivalDeploymentCanAct,
|
||||
homeworldRevivalDeploymentChoice,
|
||||
homeworldRevivalDeploymentQuantities,
|
||||
homeworldRevivalActionBlock,
|
||||
} from '../game/homeworld-revival-deployment-options';
|
||||
|
||||
@@ -82,11 +83,31 @@ function markup(game: GameView, busy = false) {
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -109,6 +130,92 @@ void test('revival controls commit the entire eligible typed group to one curren
|
||||
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', () => {
|
||||
|
||||
Reference in new issue
Block a user