Files
Dune/tests/recruits-controls.test.tsx
Quintine 0e3d6a250e Make Fremen allied revival grants cancelable
Bind the original Fremen/ally/turn grant before Karama allowance or prevention.
A prevented grant leaves intrinsic free rates and prior payments intact. Permit
late grants under the explicit provisional Recruits current-rate policy, with
shared controls, minimal bot support and native public chronology evidence.
Complete rules and browser/deployed acceptance remain open.
2026-10-24 18:16:03 +11:00

167 lines
8.0 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Recruits } from '../components/recruits';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { recruitsPlayAction } from '../game/recruits';
import { cardPresentation } from '../game/card-presentation';
import { recruitsGame } from './fixture-recruits';
function ownerGame() {
const g = recruitsGame();
const owner = g.players.find((p) => p.hand.some((c) => c.id === 'ecaz-recruits'))!;
const atreides = g.players.find((p) => p.faction === 'atreides')!;
const index = owner.hand.findIndex((c) => c.id === 'ecaz-recruits');
// A conserved fixture exchange gives the free-rate faction the actual card.
[owner.hand[index], atreides.hand[0]] = [atreides.hand[0], owner.hand[index]];
return g;
}
const render = (g: Game, viewer = 'at', busy = false) => renderToStaticMarkup(
createElement(Recruits, { game: viewGame(g, viewer), act() {}, busy }),
);
function allowRevival(g: Game) {
for (let step = 0; g.pendingRevival && step < 20; step++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((player) => player.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = applyAction(g, p.id, action); break; }
}
assert.ok(next, 'Revival decisions must have legal continuation.');
g = next;
}
assert.equal(g.pendingRevival, null);
return g;
}
void test('Recruits owner has an accessible physical-card action; rivals cannot infer its owner before play', () => {
const g = ownerGame(), html = render(g);
assert.match(html, /Play Recruits · Discard card/);
assert.match(html, /Current revival rates/);
assert.match(html, /Unlimited/);
assert.doesNotMatch(html, /disabled=""/);
assert.match(render(g, 'at', true), /disabled=""/);
assert.equal(render(g, 'fr'), '');
assert.equal(viewGame(g, 'fr').recruitsPreview?.play, null);
const card = g.players[0].hand.find((c) => c.id === 'ecaz-recruits')!;
assert.ok(cardPresentation(card).topics.some((topic) => topic.id === 'card-recruits'));
});
void test('late-paid controls allow provisional Recruits without refunding prior paid returns', () => {
const paid = allowRevival(applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 }));
assert.equal(paid.players[0].revived, 3);
assert.equal(paid.players[0].freeForcesRevived, 2);
assert.equal(paid.players[0].spice, 18);
const preview = viewGame(paid, 'at').recruitsPreview;
assert.equal(preview?.play?.blocked, null);
const action = recruitsPlayAction(preview);
assert.deepEqual(action, { type: 'card', card: 'ecaz-recruits' });
assert.ok(action);
assert.match(render(paid), /Play Recruits · Discard card/);
assert.doesNotMatch(render(paid), /disabled=""/);
const played = applyAction(paid, 'at', action);
assert.deepEqual(played.recruits, { turn: paid.turn, player: 'at', card: 'ecaz-recruits' });
assert.equal(played.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
assert.ok(!played.players[0].hand.some((card) => card.id === 'ecaz-recruits'));
assert.deepEqual(played.players.map((player) => player.spice), paid.players.map((player) => player.spice));
assert.equal(played.players[0].revived, 3);
assert.equal(played.players[0].freeForcesRevived, 2);
assert.equal(viewGame(played, 'at').revival.freeRemaining, 2);
assert.equal(viewGame(played, 'at').revival.forcesRemaining, 4);
const free = allowRevival(applyAction(played, 'at', { type: 'revive', amount: 2 }));
assert.equal(free.players[0].revived, 5);
assert.equal(free.players[0].freeForcesRevived, 4);
assert.equal(free.players[0].spice, paid.players[0].spice);
assert.equal(viewGame(free, 'at').revival.freeRemaining, 0);
assert.equal(viewGame(free, 'at').revival.forcesRemaining, 2);
});
void test('missing and inconsistent ledgers, pending transactions, wrong phase and replay remain immutable guards', () => {
const paid = allowRevival(applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 }));
const missing = structuredClone(paid);
delete missing.players[0].freeForcesRevived;
const inconsistent = structuredClone(paid);
inconsistent.players[0].freeForcesRevived = inconsistent.players[0].revived + 1;
const pending = applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 });
assert.ok(pending.pendingRevival);
const wrongPhase = ownerGame();
wrongPhase.phase = 5;
const active = applyAction(ownerGame(), 'at', { type: 'card', card: 'ecaz-recruits' });
const recovered = structuredClone(active);
const [card] = recovered.discard.splice(recovered.discard.findIndex((held) => held.id === 'ecaz-recruits'), 1);
assert.ok(card);
recovered.players[0].hand.push(card);
for (const [label, g] of [
['missing ledger', missing],
['inconsistent ledger', inconsistent],
['pending transaction', pending],
['wrong phase', wrongPhase],
['already-active recovered copy', recovered],
] as const) {
const before = structuredClone(g);
const preview = viewGame(g, 'at').recruitsPreview;
assert.ok(preview?.play?.blocked, label);
assert.equal(recruitsPlayAction(preview), null, label);
assert.throws(() => applyAction(g, 'at', { type: 'card', card: 'ecaz-recruits' }), label);
assert.deepEqual(g, before, label);
}
const beforeReplay = structuredClone(active);
assert.throws(() => applyAction(active, 'at', { type: 'card', card: 'ecaz-recruits' }));
assert.deepEqual(active, beforeReplay);
assert.match(render(missing), /aria-describedby="recruits-unavailable"/);
assert.match(render(missing), /disabled=""/);
assert.equal(render(wrongPhase), '');
assert.equal(recruitsPlayAction(undefined), null);
});
void test('resolved public rates survive JSON restoration without displaying an extra card action', () => {
const g = applyAction(ownerGame(), 'at', { type: 'card', card: 'ecaz-recruits' });
const restored = JSON.parse(JSON.stringify(g)) as Game;
for (const p of g.players) {
const html = render(restored, p.id);
assert.match(html, /Active this turn/);
assert.doesNotMatch(html, /Play Recruits · Discard card/);
assert.deepEqual(viewGame(restored, p.id).recruitsPreview, viewGame(g, p.id).recruitsPreview);
}
});
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
void test(`${level} uses the owned offer once, then legally revives at the doubled rate`, () => {
let g = ownerGame();
let view = viewGame(g, 'at');
view.players.find((p) => p.id === 'at')!.bot = level;
const action = botActions(view)[0];
assert.deepEqual(action, { type: 'card', card: 'ecaz-recruits' });
assert.deepEqual(action, recruitsPlayAction(view.recruitsPreview));
g = applyAction(g, 'at', action);
view = viewGame(g, 'at');
view.players.find((p) => p.id === 'at')!.bot = level;
const revival = botActions(view).find((candidate) => candidate.type === 'revive');
assert.ok(revival);
const done = allowRevival(applyAction(g, 'at', revival));
assert.ok(done.players[0].revived > 0);
assert.equal(done.players[0].freeForcesRevived, 4);
assert.equal(done.discard.filter((c) => c.id === 'ecaz-recruits').length, 1);
assert.ok(!botActions(view).some((candidate) => candidate.type === 'card' && candidate.card === 'ecaz-recruits'));
});
}
void test('all AI profiles retain the legal late Fremen grant after Recruits', () => {
let g = ownerGame();
g.players[0].ally = 'fr'; g.players[1].ally = 'at';
g = applyAction(g, 'at', { type: 'card', card: 'ecaz-recruits' });
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(g, 'fr');
view.players.find((p) => p.id === 'fr')!.bot = level;
const actions = botActions(view);
assert.ok(actions.length);
const grant = actions.find(action => action.type === 'grantRevival');
assert.ok(grant);
assert.doesNotThrow(() => applyAction(g, 'fr', grant));
assert.doesNotThrow(() => applyAction(g, 'fr', actions[0]));
}
});