Prototype conditional future battle Truthtrance
Verify and publish checkpoint container / container (push) Failing after 11m11s

This commit is contained in:
Quintine committed 2026-10-28 21:17:44 +11:00
1 parent 71009d73e3
commit 02695aea7c
15 files changed
+721 -8

No files matched your search

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthTraitorDecision, advanceTruthTraitorDecision, createTruthTraitorDecisionFixture,
truthTraitorDecisionClean, truthTraitorDecisionInventory, truthTraitorDecisionPlayer,
type TruthTraitorDecisionStep,
} from './fixture-truth-traitor-decision';
export const actFutureBattleLeader = actTruthTraitorDecision;
export const advanceFutureBattleLeader = advanceTruthTraitorDecision;
export const futureBattleLeaderClean = truthTraitorDecisionClean;
export const futureBattleLeaderInventory = truthTraitorDecisionInventory;
export const futureBattleLeaderPlayer = truthTraitorDecisionPlayer;
export type FutureBattleLeaderStep = TruthTraitorDecisionStep;
export type FutureBattleLeaderRecord = {
turn: number; asker: string; player: string; leader: string; answer: boolean;
battle?: string; status: 'waiting' | 'bound' | 'settled' | 'expired' | 'released';
};
export function futureBattleLeaderRecords(game: Game): FutureBattleLeaderRecord[] {
return (game as Game & { futureBattleLeaderPromises?: FutureBattleLeaderRecord[] }).futureBattleLeaderPromises ?? [];
}
/** Reuse the source's genuinely earned Basic Movement boundary. Only its
* original unopened card/spice order and existing scalar lotteries were
* selected; no battle, private plan or answer is installed. */
export function createFutureBattleLeaderFixture() {
const base = createTruthTraitorDecisionFixture();
const game = structuredClone(base.beforeShipment);
assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.equal(game.active, base.target);
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
assert.ok(!game.battle && futureBattleLeaderClean(game));
assert.ok(futureBattleLeaderPlayer(game, base.asker).hand.some(card => card.id === base.truth));
const leader = base.leaders.emperor;
const otherLeader = futureBattleLeaderPlayer(game, base.target).leaders.find(item => item.id !== leader && !item.dead && !item.usedAt);
assert.ok(otherLeader);
assert.deepEqual(futureBattleLeaderInventory(game), base.originalCards);
const question = { kind: 'futureBattleLeader', target: base.target, turn: game.turn, leader };
const ask = { type: 'truthAsk', question } as unknown as Action;
return { base, game, source: structuredClone(game), question, ask, leader, otherLeader: otherLeader.id,
asker: base.asker, target: base.target, truth: base.truth, originalCards: base.originalCards,
targetPlan: base.targetPlan, ownPlan: base.ownPlan, territory: base.territory,
shipmentStep: base.shipmentStep };
}
export type FutureBattleLeaderFixture = ReturnType<typeof createFutureBattleLeaderFixture>;
export function declareFutureBattleLeader(fixture: FutureBattleLeaderFixture): Game {
let game = actFutureBattleLeader(fixture.game, fixture.asker, { type: 'card', card: fixture.truth });
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const player = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(player);
game = actFutureBattleLeader(game, player.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
return actFutureBattleLeader(game, fixture.asker, fixture.ask);
}
export function answerFutureBattleLeader(game: Game, fixture: FutureBattleLeaderFixture, answer: 'yes' | 'no'): Game {
return actFutureBattleLeader(game, fixture.target, { type: 'truthAnswer', answer });
}
/** Use the source's actual paid shipment, then the native choice and
* preparation controls; the caller alone seals plans and traitor votes. */
export function reachFutureBattle(game: Game, fixture: FutureBattleLeaderFixture): Game {
game = actFutureBattleLeader(game, fixture.shipmentStep.actor, fixture.shipmentStep.action);
game = advanceFutureBattleLeader(game, futureBattleLeaderClean);
game = actFutureBattleLeader(game, fixture.target, { type: 'endMovement' });
game = advanceFutureBattleLeader(game, state => state.phase === 6 && futureBattleLeaderClean(state) && !!state.active && !state.battle);
const chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === fixture.territory &&
[row.attacker, row.defender].includes(fixture.target));
assert.ok(choice);
game = actFutureBattleLeader(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
assert.ok(game.battle?.event);
return advanceFutureBattleLeader(game, state => !!state.battle && futureBattleLeaderClean(state) &&
!state.battle.preparation && state.battle.preLeader?.closed !== false);
}
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
FutureBattleLeaderError,
bindFutureBattleLeader,
createFutureBattleLeader,
expireFutureBattleLeader,
releaseFutureBattleLeader,
settleFutureBattleLeader,
type FutureBattleLeaderReceipt,
type FutureBattleLeaderSource,
} from '../game/future-battle-leader';
const question = { turn: 2, asker: 'atreides', player: 'emperor', leader: 'fenring', answer: true };
const names = ['fenring', 'aramsham'];
const battle: FutureBattleLeaderSource = {
turn: 2, event: 'battle-1', attacker: 'emperor', defender: 'guild', native: true, plansOpen: true,
};
const revealed = { turn: 2, battle: 'battle-1', player: 'emperor', revealed: true, leader: 'fenring' };
void test('conditional question waits for the first actual qualifying native battle', () => {
const waiting = createFutureBattleLeader(question, names);
const original = structuredClone(waiting);
assert.equal(waiting.status, 'waiting');
for (const unrelated of [
{ ...battle, turn: 3 },
{ ...battle, attacker: 'fremen' },
{ ...battle, native: false },
]) assert.deepEqual(bindFutureBattleLeader(waiting, unrelated), waiting);
assert.throws(() => bindFutureBattleLeader(waiting, { ...battle, plansOpen: false }), FutureBattleLeaderError,
'Missing an already-opened qualifying battle must not silently bind a later one.');
const bound = bindFutureBattleLeader(waiting, battle);
assert.deepEqual(bound, { ...original, battle: 'battle-1', status: 'bound' });
assert.deepEqual(waiting, original, 'Binding cannot mutate the original receipt.');
assert.deepEqual(bindFutureBattleLeader(bound, { ...battle, event: 'battle-2' }), bound,
'A later battle must not replace the original qualifying battle.');
assert.deepEqual(JSON.parse(JSON.stringify(bound)), bound);
});
void test('yes settles only on an actual compliant revealed plan and the original battle', () => {
const bound = bindFutureBattleLeader(createFutureBattleLeader(question, names), battle);
assert.throws(() => settleFutureBattleLeader(bound, { ...revealed, revealed: false }), FutureBattleLeaderError);
assert.throws(() => settleFutureBattleLeader(bound, { ...revealed, battle: 'battle-2' }), FutureBattleLeaderError);
assert.throws(() => settleFutureBattleLeader(bound, { ...revealed, player: 'guild' }), FutureBattleLeaderError);
assert.throws(() => settleFutureBattleLeader(bound, { ...revealed, leader: 'aramsham' }), FutureBattleLeaderError);
const settled = settleFutureBattleLeader(bound, revealed);
assert.equal(settled.status, 'settled');
assert.equal(bound.status, 'bound');
assert.deepEqual(settleFutureBattleLeader(settled, revealed), settled);
assert.throws(() => settleFutureBattleLeader(settled, { ...revealed, leader: null }), FutureBattleLeaderError);
});
void test('no forbids only the named leader at that first battle', () => {
const waiting = createFutureBattleLeader({ ...question, answer: false }, names);
const bound = bindFutureBattleLeader(waiting, battle);
assert.throws(() => settleFutureBattleLeader(bound, revealed), FutureBattleLeaderError);
assert.equal(settleFutureBattleLeader(bound, { ...revealed, leader: 'aramsham' }).status, 'settled');
assert.equal(settleFutureBattleLeader(bound, { ...revealed, leader: null }).status, 'settled');
});
void test('a turn with no eligible battle expires without inventing a battle', () => {
const waiting = createFutureBattleLeader(question, names);
assert.deepEqual(expireFutureBattleLeader(waiting, { turn: 2, battlePhaseEnded: false }), waiting);
const expired = expireFutureBattleLeader(waiting, { turn: 2, battlePhaseEnded: true });
assert.equal(expired.status, 'expired');
assert.equal(expired.battle, undefined);
assert.deepEqual(bindFutureBattleLeader(expired, battle), expired);
assert.throws(() => expireFutureBattleLeader(waiting, { turn: 3, battlePhaseEnded: true }), FutureBattleLeaderError);
const bound = bindFutureBattleLeader(waiting, battle);
assert.deepEqual(expireFutureBattleLeader(bound, { turn: 2, battlePhaseEnded: true }), bound,
'An opened battle must be resolved or released, not treated as no opportunity.');
});
void test('release requires the correct turn and original bound battle', () => {
const waiting = createFutureBattleLeader(question, names);
const bound = bindFutureBattleLeader(waiting, battle);
assert.throws(() => releaseFutureBattleLeader(bound, { turn: 2, battle: 'battle-2' }), FutureBattleLeaderError);
assert.throws(() => releaseFutureBattleLeader(bound, { turn: 3, battle: 'battle-1' }), FutureBattleLeaderError);
assert.throws(() => releaseFutureBattleLeader(bound, { turn: 2 }), FutureBattleLeaderError);
const released = releaseFutureBattleLeader(bound, { turn: 2, battle: 'battle-1' });
assert.equal(released.status, 'released');
assert.deepEqual(releaseFutureBattleLeader(released, { turn: 2, battle: 'battle-1' }), released);
assert.throws(() => settleFutureBattleLeader(released, revealed), FutureBattleLeaderError);
assert.equal(releaseFutureBattleLeader(waiting, { turn: 2 }).status, 'released');
});
void test('malformed questions, saved receipts and battle identities are rejected', () => {
assert.throws(() => createFutureBattleLeader({ ...question, leader: 'unknown' }, names), FutureBattleLeaderError);
assert.throws(() => createFutureBattleLeader({ ...question, asker: question.player }, names), FutureBattleLeaderError);
assert.throws(() => createFutureBattleLeader({ ...question, turn: 0 }, names), FutureBattleLeaderError);
assert.throws(() => createFutureBattleLeader(question, ['fenring', 'fenring']), FutureBattleLeaderError);
const waiting = createFutureBattleLeader(question, names);
assert.throws(() => bindFutureBattleLeader(waiting, { ...battle, attacker: battle.defender }), FutureBattleLeaderError);
assert.throws(() => bindFutureBattleLeader(waiting, { ...battle, event: '' }), FutureBattleLeaderError);
assert.throws(() => bindFutureBattleLeader({ ...waiting, status: 'bound' } as FutureBattleLeaderReceipt, battle), FutureBattleLeaderError);
assert.throws(() => bindFutureBattleLeader({ ...waiting, status: 'waiting', battle: 'old' }, battle), FutureBattleLeaderError);
});
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TruthFutureBattleLeader } from '../components/truth-future-battle-leader';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function control(node: ReactNode, id: string): Element {
const result = elements(node).find(element => element.props['data-testid'] === id);
assert.ok(result, `Expected ${id}`);
return result;
}
function projection(): GameView {
const game = createGame('TRUTHFUTURE', newPlayer('asker', 'Asker', 'atreides'), false);
joinGame(game, newPlayer('target', 'Target', 'emperor'));
joinGame(game, newPlayer('other', 'Other', 'harkonnen'));
return viewGame(game, 'asker');
}
void test('future battle question lists only the target faction’s public native leaders', () => {
const game = projection();
const native = game.allLeaders.find(item => item.faction === 'emperor');
const other = game.allLeaders.find(item => item.faction === 'harkonnen');
assert.ok(native && other);
const props = { game, target: 'target', leader: native.id, available: true, busy: false,
onLeaderChange: (_leader: string) => {}, onAsk: (_target: string, _leader: string) => {} };
const html = renderToStaticMarkup(createElement(TruthFutureBattleLeader, props));
assert.match(html, /If you fight a battle later this turn/);
assert.match(html, /first such battle/);
assert.match(html, /there is no opportunity/);
assert.ok(html.includes(native.name));
assert.ok(!html.includes(other.name));
assert.equal(control(TruthFutureBattleLeader(props), 'truth-future-battle-leader-ask').props.disabled, false);
});
void test('authoring callbacks reject unavailable, missing and foreign leaders', () => {
const game = projection();
const native = game.allLeaders.filter(item => item.faction === 'emperor');
const foreign = game.allLeaders.find(item => item.faction === 'harkonnen');
assert.ok(native.length >= 2 && foreign);
const changes: string[] = [], asks: string[][] = [];
const props = { game, target: 'target', leader: '', available: true, busy: false,
onLeaderChange: (id: string) => changes.push(id),
onAsk: (target: string, id: string) => asks.push([target, id]) };
const change = (tree: ReactNode, value: string) =>
(control(tree, 'truth-future-battle-leader-select').props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
const ask = (tree: ReactNode) => (control(tree, 'truth-future-battle-leader-ask').props.onClick as () => void)();
const empty = TruthFutureBattleLeader(props);
assert.equal(control(empty, 'truth-future-battle-leader-ask').props.disabled, true);
change(empty, foreign.id); ask(empty); assert.deepEqual(changes, []); assert.deepEqual(asks, []);
change(empty, native[1].id); assert.deepEqual(changes, [native[1].id]);
const selected = TruthFutureBattleLeader({ ...props, leader: native[1].id });
ask(selected); assert.deepEqual(asks, [['target', native[1].id]]);
for (const override of [{ busy: true }, { available: false }, { leader: foreign.id }, { target: 'asker' }]) {
const tree = TruthFutureBattleLeader({ ...props, leader: native[1].id, ...override });
assert.equal(control(tree, 'truth-future-battle-leader-ask').props.disabled, true);
ask(tree);
}
assert.equal(asks.length, 1);
});
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actFutureBattleLeader as act, advanceFutureBattleLeader as advance,
answerFutureBattleLeader as answer, createFutureBattleLeaderFixture as fixture,
declareFutureBattleLeader as declare, futureBattleLeaderClean as clean,
futureBattleLeaderInventory as inventory, futureBattleLeaderRecords as records,
reachFutureBattle as reachBattle,
} from './fixture-truth-future-battle-leader';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
for (const expected of ['yes', 'no'] as const) {
void test(`Basic next battle leader ${expected} binds the first real battle and constrains its native plan`, () => {
const f = fixture();
const asked = declare(f);
assert.equal(asked.truthtrance?.stage, 'answer');
assert.deepEqual(asked.truthtrance?.question, f.question);
let game = answer(asked, f, expected);
assert.equal(game.truthtrance, null);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(records(game).length, 1);
assert.equal(records(game)[0].status, 'waiting');
assert.equal(records(game)[0].player, f.target);
assert.equal(records(game)[0].leader, f.leader);
assert.equal(records(game)[0].answer, expected === 'yes');
rejected(game, f.target, { type: 'truthAnswer', answer: expected });
assert.equal(records(game).length, 1);
game = reachBattle(game, f);
assert.equal(records(game)[0].status, 'bound');
assert.equal(records(game)[0].battle, game.battle!.event);
const yesPlan = { ...f.targetPlan, leader: f.leader } as Action;
const noPlan = { ...f.targetPlan, leader: f.otherLeader } as Action;
rejected(game, f.target, expected === 'yes' ? noPlan : yesPlan);
game = act(game, f.target, expected === 'yes' ? yesPlan : noPlan);
assert.equal(records(game)[0].status, 'bound');
game = act(game, f.asker, f.ownPlan);
assert.ok(game.battle?.revealed);
assert.equal(records(game)[0].status, 'settled');
assert.equal(records(game).length, 1);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
}
void test('Basic next battle leader question expires without creating a battle or forcing a shipment', () => {
const f = fixture();
for (const expected of ['yes', 'no'] as const) {
let game = answer(declare(f), f, expected);
const before = structuredClone(game.players.find(player => player.id === f.target));
game = act(game, f.target, { type: 'endMovement' });
game = advance(game, state => state.turn !== f.game.turn || state.phase > 6 || state.status === 'finished');
assert.equal(game.battle, null);
assert.equal(records(game).length, 1);
assert.equal(records(game)[0].status, 'expired');
const after = game.players.find(player => player.id === f.target); assert.ok(before && after);
assert.equal(after.reserves, before.reserves);
assert.deepEqual(after.forces, before.forces);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
assert.ok(clean(game));
}
});
void test('future battle questions use a current turn, another seat and that faction’s native public leader', () => {
const f = fixture();
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const player = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(player);
game = act(game, player.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
const invalid = [
{ ...f.question, turn: f.game.turn + 1 },
{ ...f.question, target: f.asker },
{ ...f.question, leader: f.base.leaders.atreides },
{ ...f.question, leader: 'unknown-leader' },
];
for (const question of invalid)
rejected(game, f.asker, { type: 'truthAsk', question } as unknown as Action);
const asked = act(game, f.asker, f.ask);
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
});
void test('each AI profile chooses a legal conditional answer and honors it in the real battle', () => {
for (const difficulty of DIFFICULTIES) {
const f = fixture();
const asked = declare(f);
const respondent = asked.players.find(player => player.id === f.target)!;
respondent.bot = difficulty;
const reply = botActions(viewGame(asked, f.target)).find(action => action.type === 'truthAnswer');
assert.ok(reply, difficulty);
let game = act(asked, f.target, reply);
assert.equal(records(game)[0].status, 'waiting');
game = reachBattle(game, f);
const planned = botActions(viewGame(game, f.target)).find(action => action.type === 'battlePlan');
assert.ok(planned, difficulty);
game = act(game, f.target, planned);
game = act(game, f.asker, f.ownPlan);
assert.equal(records(game)[0].status, 'settled');
}
});