Coordinate Fremen grants in ordered revival
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 09:19:17 +11:00
1 parent 64706cc7e0
commit 220d78c779
11 files changed
+429 -12

No files matched your search

@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import {
actFremenRevivalGrant as act,
advanceFremenRevivalGrant as advance,
fremenRevivalGrantClean as clean,
fremenRevivalGrantPlayer as player,
passFremenRevivalGrantResponses as pass,
} from './fixture-fremen-revival-grant';
/** Actual Advanced public setup and first ordered Revival. Reversing the two
* original lobby seats changes the real storm order. At Revival only, the
* reciprocal prior alliance and three ordinary Atreides casualties are staged;
* the counters move from his reserves into Tanks, without inventing supply.
* This controlled alliance is not a claim about Nexus history or full games. */
function source(fremenFirstSeat: boolean) {
const first = fremenFirstSeat ? newPlayer('fremen', 'Fremen', 'fremen')
: newPlayer('atreides', 'Atreides', 'atreides');
const second = fremenFirstSeat ? newPlayer('atreides', 'Atreides', 'atreides')
: newPlayer('fremen', 'Fremen', 'fremen');
let game = createGame('FREMENORDEREDBOT', first, true);
joinGame(game, second);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
for (const p of game.players) game = act(game, p.id, { type: 'ready' });
game = act(game, game.host, { type: 'start', advancedPreview: true });
game = advance(game, state => state.status === 'playing');
// Select only two original unopened ordinary blows, avoiding incidental worms.
for (let position = 0; position < 2; position++) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = advance(game, state => state.phase === 4 && clean(state));
assert.ok(game.revivalOrder);
const ally = player(game, 'atreides'), fremen = player(game, 'fremen');
fremen.ally = ally.id; ally.ally = fremen.id;
assert.ok(ally.reserves >= 3);
ally.reserves -= 3; ally.tanks += 3;
return game;
}
let cached: { earlier: Game; later: Game } | undefined;
function sources() {
if (cached) return cached;
const options = [source(true), source(false)];
const earlier = options.find(g => g.order.indexOf('fremen') < g.order.indexOf('atreides'));
const later = options.find(g => g.order.indexOf('fremen') > g.order.indexOf('atreides'));
assert.ok(earlier && later, 'Opposite real seat assignments must produce both relative Revival orders');
cached = { earlier, later }; return cached;
}
function awaitingAlly(order: 'earlier' | 'later', level: Difficulty = 'Easy') {
let game = structuredClone(sources()[order]);
while (game.revivalOrder?.remaining[0] !== 'atreides') {
const actor = game.revivalOrder?.remaining[0]; assert.ok(actor);
game = act(game, actor, { type: 'ready' });
}
assert.ok(game.revivalOrder);
assert.equal(game.revivalOrder.remaining[0], 'atreides');
assert.equal(game.revivalOrder.completed.includes('fremen'), order === 'earlier');
game = act(game, 'atreides', { type: 'setAutopilot', difficulty: level });
return game;
}
function swapKaramaToObserver(game: Game) {
const observer = player(game, 'emperor');
if (observer.hand.some(card => card.effect === 'karama')) return observer.hand.find(card => card.effect === 'karama')!.id;
const held = observer.hand[0]; assert.ok(held);
const pools = [game.deck, game.discard, ...game.players.filter(p => p.id !== observer.id).map(p => p.hand)];
const pool = pools.find(cards => cards.some(card => card.effect === 'karama')); assert.ok(pool);
const index = pool.findIndex(card => card.effect === 'karama');
const card = pool[index]; pool[index] = held; observer.hand[0] = card;
return card.id;
}
for (const order of ['earlier', 'later'] as const) for (const level of DIFFICULTIES) void test(
`Advanced ${level} allied bot waits for human Fremen ${order} in ordered Revival and uses a real grant`, () => {
let game = awaitingAlly(order, level);
const before = structuredClone(game.revivalOrder), ally = player(game, 'atreides');
const tanks = ally.tanks, spice = ally.spice;
game = runBots(game, 1);
assert.deepEqual(game.revivalOrder, before);
assert.equal(player(game, 'atreides').revived, 0);
assert.equal(player(game, 'atreides').tanks, tanks);
game = act(game, 'fremen', { type: 'grantRevival' });
if (game.response) game = pass(game);
assert.equal(viewGame(game, 'atreides').revival.freeRemaining, 3);
const offered = botActions(viewGame(game, 'atreides')).find(action => action.type === 'revive' && action.amount === 3);
assert.ok(offered);
game = runBots(game, 1);
assert.equal(player(game, 'atreides').tanks, tanks - 3);
assert.equal(player(game, 'atreides').spice, spice);
assert.equal(player(game, 'atreides').freeForcesRevived, 3);
},
);
for (const order of ['earlier', 'later'] as const) void test(
`Fremen ${order} in the ordered queue can release an automated ally without forfeiting a later grant`, () => {
let game = awaitingAlly(order);
const before = structuredClone(game.revivalOrder);
assert.ok(!viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased);
game = act(game, 'fremen', { type: 'releaseFremenRevivalWait', target: 'atreides' });
assert.deepEqual(game.revivalOrder, before);
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased, true);
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.blocked, null);
assert.equal(game.freeRevival.includes('atreides'), false);
let granted = act(game, 'fremen', {type:'grantRevival'});
if (granted.response) granted = pass(granted);
assert.equal(granted.freeRevival.includes('atreides'), true);
const resumed = runBots(game, 1);
assert.ok(player(resumed, 'atreides').revived > 0 || resumed.ready.includes('atreides'));
},
);
void test('Fremen can release a future ally, while other seats and stale releases are rejected', () => {
let game = structuredClone(sources().earlier);
game = act(game, 'atreides', {type:'setAutopilot', difficulty:'Easy'});
assert.ok(game.revivalOrder?.remaining.includes('atreides'));
assert.notEqual(game.revivalOrder?.remaining[0], 'atreides');
const before = structuredClone(game);
assert.throws(() => act(game, 'emperor', {type:'releaseFremenRevivalWait', target:'atreides'}));
assert.deepEqual(game, before);
game = act(game, 'fremen', {type:'releaseFremenRevivalWait', target:'atreides'});
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased, true);
assert.equal(viewGame(game, 'emperor').fremenRevivalGrant, null);
const released = structuredClone(game);
assert.throws(() => act(game, 'fremen', {type:'releaseFremenRevivalWait', target:'atreides'}));
assert.deepEqual(game, released);
});
void test('real Karama prevention releases the Advanced bot with its intrinsic free rate', () => {
let game = awaitingAlly('later', 'Medium');
// Conserved exchange of original Treachery cards gives Emperor a counter.
const karama = swapKaramaToObserver(game);
game = act(game, 'fremen', { type: 'grantRevival' });
assert.equal(game.response?.kind, 'fremenRevivalGrant');
game = act(game, 'emperor', { type: 'card', card: karama, mode: 'cancel' });
assert.ok(game.revivalRules?.fremenGrantBlocked?.includes('atreides'));
assert.equal(viewGame(game, 'atreides').revival.freeRemaining, 2);
const resumed = runBots(game, 1);
assert.ok(player(resumed, 'atreides').revived > 0 || resumed.ready.includes('atreides'));
});
void test('Advanced bots do not wait for an unnecessary grant or an automated Fremen', () => {
for (const variant of ['two-tanks', 'automated-fremen'] as const) {
let game = awaitingAlly('later');
if (variant === 'two-tanks') {
// Return one ordinary counter from Tanks to its original reserve supply.
player(game, 'atreides').tanks--; player(game, 'atreides').reserves++;
} else game = act(game, 'fremen', { type: 'setAutopilot', difficulty: 'Easy' });
game = runBots(game, 1);
assert.ok(player(game, 'atreides').revived > 0 || game.ready.includes('atreides'));
}
});
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FremenRevivalGrant } from '../components/fremen-revival-grant';
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
/** Projection-only control fixtures; engine tests own the actual alliance and
* ordered Revival transitions. The ally's private revival inventory is not read. */
function projected(group = false): GameView {
const game = createGame('ORDEREDGRANTCONTROLS', newPlayer('f', 'Fremen', 'fremen'), true);
game.players.push(newPlayer('a', 'Ally', 'atreides'));
game.players.push(newPlayer('e', 'Emperor', 'emperor'));
Object.assign(game, { status: 'playing', phase: 4, turn: 3, order: ['f', 'e', 'a'] });
game.players[0].ally = 'a';
game.players[1].ally = 'f';
const view = viewGame(game, 'f');
view.players.find(player => player.id === 'a')!.autopilot = 'Easy';
view.players.find(player => player.id === 'e')!.bot = 'Medium';
view.revivalOrder = { version: 1, turn: 3, original: ['f', 'e', 'a'],
remaining: ['e', 'a'], completed: ['f'], started: false };
view.fremenRevivalGrant = { recipient: 'a', granted: false, blocked: null, waitReleased: false };
if (group) view.fremenGroupRevivalGrant = [
{ recipient: 'a', granted: false, blocked: null, waitReleased: false },
{ recipient: 'e', granted: false, blocked: null, waitReleased: false },
];
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(FremenRevivalGrant, { game, busy, act() {} }));
}
function click(node: ReactNode, target: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{ children?: ReactNode; onClick?: () => void; 'data-testid'?: string }>(child)) continue;
if (child.props['data-testid'] === `fremen-revival-wait-release-${target}` && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children, target)) return true;
}
return false;
}
function release(game: GameView, target: string, busy = false): Action[] {
const actions: Action[] = [];
click(FremenRevivalGrant({ game, busy, act(action) { actions.push(action); } }), target);
return actions;
}
void test('Fremen can release a later automated ally before its ordered Revival turn', () => {
const game = projected();
assert.match(markup(game), /Continue without waiting/);
assert.match(markup(game), /may still grant later while Revival remains open/);
assert.deepEqual(release(game, 'a'), [{type:'releaseFremenRevivalWait', target:'a'}]);
});
void test('release is disabled during a busy window or after release without hiding grant', () => {
const game = projected();
assert.deepEqual(release(game, 'a', true), []);
game.fremenRevivalGrant!.waitReleased = true;
assert.match(markup(game), /Ally can continue without waiting for your grant/);
assert.match(markup(game), /Grant ally free revival/);
assert.deepEqual(release(game, 'a'), []);
game.truthtrance = {} as NonNullable<GameView['truthtrance']>;
game.fremenRevivalGrant!.waitReleased = false;
assert.deepEqual(release(game, 'a'), []);
});
void test('group controls release each recipient independently without reading private forces', () => {
const game = projected(true);
for (const ally of game.players.filter(player => player.id !== 'f')) {
for (const field of ['spice', 'tanks', 'deadForces', 'revived', 'freeForcesRevived'])
Object.defineProperty(ally, field, {get() { throw new Error(`Read private ${field}`); }});
}
assert.deepEqual(release(game, 'a'), [{type:'releaseFremenRevivalWait', target:'a'}]);
assert.deepEqual(release(game, 'e'), [{type:'releaseFremenRevivalWait', target:'e'}]);
game.fremenGroupRevivalGrant![0].waitReleased = true;
assert.deepEqual(release(game, 'a'), []);
assert.deepEqual(release(game, 'e'), [{type:'releaseFremenRevivalWait', target:'e'}]);
});
void test('release is absent for human, finished, blocked or nonordered allies', () => {
for (const condition of ['human', 'finished', 'blocked', 'basic'] as const) {
const game = projected();
if (condition === 'human') game.players.find(player => player.id === 'a')!.autopilot = undefined;
if (condition === 'finished') game.revivalOrder!.remaining = ['e'];
if (condition === 'blocked') game.fremenRevivalGrant!.blocked = 'Grant prevented.';
if (condition === 'basic') game.advanced = false;
assert.doesNotMatch(markup(game), /Continue without waiting/, condition);
assert.deepEqual(release(game, 'a'), [], condition);
}
});
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { runBots } from '../game/bots';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { createRevivalOrder } from '../game/revival-order';
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
const seat = (game: Game, id: string) => {
const found = game.players.find(player => player.id === id); assert.ok(found); return found;
};
/** Advanced group is built by genuine pair and group Nexus actions. The Nexus
* itself, original public order and subsequent Revival phase are staged, not a
* native full game. Atreides and Harkonnen each move four of their own ordinary
* reserves to Tanks; the queue is made with the production order helper, and
* Fremen actually finishes his opportunity before the first ally acts. */
function fixture() {
let game = createGame('ORDEREDGROUPREVIVAL', newPlayer('f', 'Fremen', 'fremen'), true);
game.players.push(newPlayer('a', 'Atreides', 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'));
Object.assign(game, {status: 'playing', phase: 1, turn: 2, nexus: true,
order: ['f', 'a', 'h', 'g'], ready: [], storm: 18, deck: baseDeck(),
response: null, decision: null, phaseOpening: null});
game.coalitionState = emptyCoalitionState();
game = act(game, 'f', {type: 'alliance', target: 'a'});
game = act(game, 'a', {type: 'alliance', target: 'f'});
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
game = act(game, 'f', {type: 'coalitionPropose', groupId: group.id, entrant: 'h',
expectedRevision: game.coalitionState!.revision});
for (const id of ['a', 'h']) {
const proposal = game.coalitionState!.proposal; assert.ok(proposal);
game = act(game, id, {type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus,
turn: proposal.turn, accept: true, expectedRevision: game.coalitionState!.revision});
}
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'f')!.members, ['a', 'f', 'h']);
Object.assign(game, {phase: 4, nexus: false, ready: [], response: null, decision: null,
active: 'f', phaseOpening: null});
for (const player of game.players) {
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
player.hand = []; player.traitors = []; player.traitorChoices = [];
}
for (const id of ['a', 'h']) {seat(game, id).reserves -= 4; seat(game, id).tanks += 4;}
game.revivalOrder = createRevivalOrder({turn: game.turn, order: game.order, ready: game.ready});
game = act(game, 'f', {type: 'ready'});
game = act(game, 'a', {type: 'setAutopilot', difficulty: 'Easy'});
game = act(game, 'h', {type: 'setAutopilot', difficulty: 'Hard'});
assert.equal(game.revivalOrder?.remaining[0], 'a');
return game;
}
void test('Advanced group release is recipient-specific across consecutive automated Revival turns', () => {
let game = fixture();
const original = structuredClone(game.revivalOrder);
game = runBots(game, 12);
assert.deepEqual(game.revivalOrder, original);
assert.equal(seat(game, 'a').revived, 0); assert.equal(seat(game, 'h').revived, 0);
game = act(game, 'f', {type: 'releaseFremenRevivalWait', target: 'a'});
const projection = viewGame(game, 'f').fremenGroupRevivalGrant;
assert.equal(projection?.find(row => row.recipient === 'a')?.waitReleased, true);
assert.ok(!projection?.find(row => row.recipient === 'h')?.waitReleased);
game = runBots(game, 30);
assert.ok(seat(game, 'a').revived > 0);
assert.ok(game.revivalOrder?.completed.includes('a'));
assert.equal(game.revivalOrder?.remaining[0], 'h');
assert.equal(seat(game, 'h').revived, 0); assert.equal(seat(game, 'h').tanks, 4);
assert.equal(game.freeRevival.includes('a'), false); assert.equal(game.freeRevival.includes('h'), false);
});