Extend conditional battle questions across previews
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 02:21:12 +11:00
1 parent 0e52c25355
commit 50703664cf
13 files changed
+336 -24

No files matched your search

+1 -1
View File
@@ -128,7 +128,7 @@ void test('real pre-dial Storm and active Spice Blow hide targets and reject the
for (const [f, game] of [[source, storm], [spiceSource, spice]] as const) {
assert.deepEqual(viewGame(game, f.asker).truthFutureBattleLeaderTargets, []);
const before = structuredClone(game);
assert.throws(() => ask({ ...f, game }), /quiet Basic/);
assert.throws(() => ask({ ...f, game }), /supported quiet/);
assert.deepEqual(game, before);
}
});
@@ -0,0 +1,140 @@
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 { createTruthMovementAdvancedFixture, actTruthMovementAdvanced as act,
truthMovementAdvancedClean as clean } from './fixture-truth-movement-advanced';
import { createTruthMovementIxianFixture } from './fixture-truth-movement-ixian';
import { advanceFutureShipment as advance } from './fixture-truthtrance-future-shipment';
import { futureBattleLeaderRecords as records } from './fixture-truth-future-battle-leader';
import { futureBattleLeaderWindow } from '../game/future-battle-leader';
type Fixture = ReturnType<typeof createTruthMovementAdvancedFixture>;
/** The fixture earns the physical Truthtrance and Atreides' Arrakeen army via
* native Advanced setup and phase actions. Nothing installs a battle or answer. */
function ask(f: {game: Game; target: string; asker: string; truth: string}): { game: Game; leader: string; other: string } {
let game = f.game;
const target = game.players.find(player => player.id === f.target)!;
const leader = target.leaders[0].id, other = target.leaders[1].id;
assert.ok(viewGame(game, f.asker).truthFutureBattleLeaderTargets.includes(f.target));
game = act(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');
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'futureBattleLeader', target: f.target, turn: game.turn, leader,
} } as unknown as Action);
assert.equal(game.truthtrance?.stage, 'answer');
return { game, leader, other };
}
function battle(game: Game, f: Fixture): Game {
game = act(game, f.target, { type: 'endMovement' });
game = advance(game, state => state.phase === 5 && state.active === f.asker && clean(state));
game = act(game, f.asker, { type: 'ship', amount: 1, elite: 0,
territory: 'arrakeen', sector: 10, allyPayment: 0 });
game = advance(game, clean);
game = act(game, f.asker, { type: 'endMovement' });
game = advance(game, state => state.phase === 6 && clean(state) && !!state.active && !state.battle);
const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === 'arrakeen');
assert.ok(choice);
game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
return advance(game, state => !!state.battle && clean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false);
}
for (const answer of ['yes', 'no'] as const) {
void test(`Advanced classic ${answer} binds and settles in a real paid-support battle`, () => {
const f = createTruthMovementAdvancedFixture({ hajr: false });
const asked = ask(f);
assert.ok(viewGame(asked.game, f.target).truthFutureBattleLeaderAnswers?.includes(answer));
let game = act(asked.game, f.target, { type: 'truthAnswer', answer });
assert.equal(records(game)[0].status, 'waiting');
game = battle(game, f);
assert.equal(records(game)[0].status, 'bound');
assert.equal(records(game)[0].battle, game.battle!.event);
const plan = (leader: string): Action => ({ type: 'battlePlan', leader, dial: 1,
support: 1, allyPayment: 0, weapon: null, defense: null });
const rejected = plan(answer === 'yes' ? asked.other : asked.leader);
const before = structuredClone(game);
assert.throws(() => act(game, f.target, rejected));
assert.deepEqual(game, before);
game = act(game, f.target, plan(answer === 'yes' ? asked.leader : asked.other));
const emperor = game.players.find(player => player.id === f.asker)!;
game = act(game, f.asker, { type: 'battlePlan', leader: emperor.leaders[0].id,
dial: 0, support: 0, allyPayment: 0, weapon: null, defense: null });
assert.ok(game.battle?.revealed);
assert.equal(records(game)[0].status, 'settled');
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
});
}
void test('Advanced conditional question expires without requiring a battle', () => {
const f = createTruthMovementAdvancedFixture({ hajr: false });
const asked = ask(f);
let game = act(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(records(game)[0].status, 'waiting');
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)[0].status, 'expired');
});
void test('all bot profiles provide an available Advanced conditional answer', () => {
const f = createTruthMovementAdvancedFixture({ hajr: false });
const asked = ask(f);
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(asked.game);
game.players.find(player => player.id === f.target)!.bot = difficulty;
const reply = botActions(viewGame(game, f.target)).find(action => action.type === 'truthAnswer');
assert.ok(reply, difficulty);
const answered = act(game, f.target, reply);
assert.equal(records(answered)[0].status, 'waiting');
}
});
void test('Advanced Ixians bind and settle their own leader in a real Cyborg battle', () => {
const f = createTruthMovementIxianFixture();
const asked = ask(f);
let game = act(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(records(game)[0].status, 'waiting');
game = act(game, f.target, f.matchingMove);
game = advance(game, clean);
game = act(game, f.target, { type: 'endMovement' });
game = advance(game, state => state.phase === 5 && state.active === f.asker && clean(state));
game = act(game, f.asker, { type: 'ship', amount: 1, elite: 0,
territory: f.destination.territory, sector: f.destination.sector, allyPayment: 0 });
game = advance(game, clean);
game = act(game, f.asker, { type: 'endMovement' });
game = advance(game, state => state.phase === 6 && clean(state) && !!state.active && !state.battle);
const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === f.destination.territory);
assert.ok(choice);
game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
game = advance(game, state => !!state.battle && clean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false);
assert.equal(records(game)[0].status, 'bound');
assert.equal(records(game)[0].battle, game.battle!.event);
const common = { dial: 0, support: 0, allyPayment: 0, weapon: null, defense: null };
const before = structuredClone(game);
assert.throws(() => act(game, f.target, { type: 'battlePlan', leader: asked.other, ...common }));
assert.deepEqual(game, before);
game = act(game, f.target, { type: 'battlePlan', leader: asked.leader, ...common });
game = act(game, f.asker, { type: 'battlePlan',
leader: game.players.find(player => player.id === f.asker)!.leaders[0].id, ...common });
assert.ok(game.battle?.revealed);
assert.equal(records(game)[0].status, 'settled');
});
void test('Advanced optional-module and Ecaz previews remain outside the shared question window', () => {
const f = createTruthMovementAdvancedFixture({ hajr: false });
assert.ok(futureBattleLeaderWindow(f.game));
assert.equal(futureBattleLeaderWindow({ ...f.game, homeworlds: { custody: null } }), false);
assert.equal(futureBattleLeaderWindow({ ...f.game, ecazOccupyPreview: true }), false);
});
@@ -32,7 +32,7 @@ void test('future battle question lists only the target faction’s public nativ
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, /If you submit a battle plan later this turn/);
assert.match(html, /first such battle/);
assert.match(html, /there is no opportunity/);
assert.ok(html.includes(native.name));
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, location as locationKey } from '../game/board';
import { advanceToNextStorm } from './fixture-advanced-source';
import { basicEcazOccupyFixture, basicEcazOccupyBattleAction } from './fixture-basic-ecaz-occupy';
import { nextBasicEcazNativeStep } from './fixture-basic-ecaz-native';
import { settleEcazOccupyArrival, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { futureBattleLeaderRecords as records } from './fixture-truth-future-battle-leader';
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
function next(game: Game): Game {
const step = nextBasicEcazNativeStep(game);
assert.ok(step, `The original continuation needs an action in phase ${game.phase}.`);
return applyAction(game, step.actor, step.action);
}
function advance(game: Game, until: (game: Game) => boolean): Game {
for (let i = 0; i < 1600 && !until(game); i++) game = next(game);
assert.ok(until(game), 'Native continuation did not reach the requested boundary.');
return game;
}
function arrangeBlow(game: Game, worm: boolean, position: number) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed
: 'territory' in card && card.territory !== 'the_great_flat'));
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
/** The fixture starts with a genuine Basic deal. As in the original Occupy
* fixture, conserved board counters are returned, unopened Spice Cards are
* reordered, and a rival army, the shipping budgets, two Truthtrance cards and
* Karama are staged. Alliance, answers, allied shipments, battle and plan
* choices are real native actions. */
function source() {
const original = basicEcazOccupyFixture({ chooseBattle: false });
let game = structuredClone(original.afterSetup);
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
player.elites.forces = {};
}
}
// This fixture deliberately supplies a test-bank budget for the actual
// later paid shipments, as the selected battle needs four forces per ally.
for (const id of [original.ecaz, original.ally])
game.players.find(p => p.id === id)!.spice = 30;
// Stage before either actual auction can move an unopened face to discard.
const first = stageEcazOccupyCard(game, original.opponent, card => card.effect === 'truthtrance');
const second = stageEcazOccupyCard(game, original.opponent, card => card.effect === 'truthtrance' && card.id !== first);
stageEcazOccupyCard(game, original.opponent, card => card.effect === 'karama');
arrangeBlow(game, false, 0);
arrangeBlow(game, false, 1);
game = advance(game, g => g.phase === 5 && clean(g));
while (game.phase === 5) game = clean(game) ? applyAction(game, game.active!, { type: 'endMovement' }) : next(game);
game = advanceToNextStorm(game);
arrangeBlow(game, true, 0);
arrangeBlow(game, false, 1);
arrangeBlow(game, false, 2);
game = advance(game, g => g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
game = applyAction(game, original.ecaz, { type: 'alliance', target: original.ally });
game = applyAction(game, original.ally, { type: 'alliance', target: original.ecaz });
game = advance(game, g => g.phase === 5 && clean(g));
const sector = TERRITORIES.find(t => t.id === original.territory)!.sectors.find(s => s !== game.storm)!;
const location = locationKey(original.territory, sector);
const rival = game.players.find(p => p.id === original.opponent)!;
assert.ok(rival.reserves >= 8);
rival.reserves -= 8;
rival.forces[location] = 8;
const named: Record<string, string> = {};
for (const [target, card] of [[original.ecaz, first], [original.ally, second]] as const) {
const player = game.players.find(p => p.id === target)!;
const leader = player.leaders.find(l => !l.dead && !l.usedAt)!;
assert.ok(leader);
named[target] = leader.id;
assert.ok(viewGame(game, original.opponent).truthFutureBattleLeaderTargets.includes(target));
game = applyAction(game, original.opponent, { type: 'card', card });
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(actor);
game = applyAction(game, actor.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
game = applyAction(game, original.opponent, { type: 'truthAsk', question: {
kind: 'futureBattleLeader', target, turn: game.turn, leader: leader.id,
} } as Action);
game = applyAction(game, target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(records(game).find(row => row.player === target)?.status, 'waiting');
}
while (game.phase === 5) {
if (!clean(game)) { game = next(game); continue; }
const actor = game.active!;
if (actor === original.ecaz || actor === original.ally) {
game = settleEcazOccupyArrival(applyAction(game, actor, { type: 'ship',
territory: original.territory, sector, amount: 4 }));
}
game = applyAction(game, actor, { type: 'endMovement' });
}
game = advance(game, g => g.phase === 6 && clean(g) && !g.battle && !!g.active);
return { ...original, game, named, location };
}
for (const lead of ['ecaz', 'ally'] as const) for (const canceled of [false, true])
void test(`Basic Occupy ${lead} lead ${canceled ? 'canceled' : 'allowed'} binds only its actual plan owner`, () => {
const f = source();
let game = f.game;
const battle = basicEcazOccupyBattleAction(f);
game = applyAction(game, battle.actor, battle.action);
assert.equal(game.decision?.kind, 'ecazBattleLead');
assert.ok(records(game).every(row => row.status === 'waiting' && !row.battle));
const selected = lead === 'ecaz' ? f.ecaz : f.ally;
const nonleader = selected === f.ecaz ? f.ally : f.ecaz;
game = applyAction(game, f.ecaz, { type: 'decision', event: game.decision!.event, lead: selected });
assert.equal(game.response?.kind, 'ecazOccupy');
assert.ok(records(game).every(row => row.status === 'waiting' && !row.battle));
if (canceled) {
const card = game.players.find(p => p.id === f.opponent)!.hand.find(c => c.effect === 'karama');
assert.ok(card);
game = applyAction(game, f.opponent, { type: 'card', mode: 'cancel', card: card.id });
}
game = advance(game, g => !!g.battle && clean(g) && !g.battle.preparation &&
(!g.battle.preLeader || g.battle.preLeader.closed));
assert.equal(game.battle!.ecazOccupy!.canceled, canceled);
assert.equal(records(game).find(row => row.player === selected)?.status, 'bound');
assert.equal(records(game).find(row => row.player === selected)?.battle, game.battle!.event);
assert.equal(records(game).find(row => row.player === nonleader)?.status, 'waiting');
const profile = viewGame(game, selected).battle!.ecazOccupy!.profile!;
const wrong = game.players.find(p => p.id === selected)!.leaders.find(l => l.id !== f.named[selected] && !l.dead && !l.usedAt)!;
const before = structuredClone(game);
assert.throws(() => applyAction(game, selected, { type: 'battlePlan', dial: profile.fixedEcazDial,
support: 0, leader: wrong.id }));
assert.deepEqual(game, before);
game = applyAction(game, selected, { type: 'battlePlan', dial: profile.fixedEcazDial,
support: 0, leader: f.named[selected] });
const opponentLeader = game.players.find(p => p.id === f.opponent)!.leaders.find(l => !l.dead && !l.usedAt)!;
game = applyAction(game, f.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader.id });
assert.equal(records(game).find(row => row.player === selected)?.status, 'settled');
game = advance(game, g => !g.battle && (g.phase > 6 || g.turn > f.game.turn));
assert.equal(records(game).find(row => row.player === nonleader)?.status, 'expired');
});
@@ -88,11 +88,12 @@ void test('Basic Richese answer survives physical Truthtrance discard and Semuta
assert.deepEqual(truthtranceRichesePromisesInventory(game), f.originalCards);
});
void test('Ecaz participation guards the future-battle question in the view and parser', () => {
void test('Advanced Ecaz participation guards the future-battle question in the view and parser', () => {
const f = createTruthMovementChoamFixture();
// Controlled guard probe: this extra seat is staged on a genuine quiet
// CHOAM source; it does not claim to be an Ecaz setup or played journey.
let game = structuredClone(f.beforeShipment);
game.advanced = true;
game.expansions.push('ecaz');
game.players.push(newPlayer('ecaz-guard', 'Ecaz', 'ecaz'));
assert.deepEqual(viewGame(game, f.asker).truthFutureBattleLeaderTargets, []);