Prototype conditional future battle Truthtrance
Verify and publish checkpoint container / container (push) Failing after 11m11s
Verify and publish checkpoint container / container (push) Failing after 11m11s
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user