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.
167 lines
8.0 KiB
TypeScript
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]));
|
|
}
|
|
});
|