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

+4 -4
View File
@@ -37,9 +37,9 @@ export function TruthFutureBattleLeader({ game, target, leader, available, busy,
</select>
</label>}
<blockquote data-testid="truth-future-battle-leader-preview">
If you fight a battle later this turn, will you use {selected?.name ?? '[leader]'} in your first such battle?
If you submit a battle plan later this turn, will you use {selected?.name ?? '[leader]'} in your first such battle?
</blockquote>
<p className="notice">If you do not fight a later battle this turn, there is no opportunity to use that leader.</p>
<p className="notice">If you submit no later battle plan this turn, there is no opportunity to use that leader.</p>
<Button className="game-action" data-testid="truth-future-battle-leader-ask" disabled={blocked}
onClick={() => { if (!blocked) onAsk(target, leader); }}>Ask publicly</Button>
</>;
@@ -53,8 +53,8 @@ export function FutureBattleLeaderPromises({ game }: {game: GameView}) {
return <section className="notice" aria-label="Your conditional battle answer">
{promises.map((row, index) => <p key={`${row.turn}-${row.player}-${index}`}>
Your Truthtrance answer commits you to {row.answer ? 'use' : 'not use'}{' '}
{game.allLeaders.find(leader => leader.id === row.leader)?.name ?? row.leader} in your first later battle this turn.
{row.status === 'waiting' ? ' If you fight no battle, there is no opportunity.' : ' This battle is the opportunity.'}
{game.allLeaders.find(leader => leader.id === row.leader)?.name ?? row.leader} in the first battle for which you submit a plan this turn.
{row.status === 'waiting' ? ' If you submit no battle plan, there is no opportunity.' : ' This battle is the opportunity.'}
</p>)}
</section>;
}
+12
View File
@@ -17,6 +17,18 @@ open mode gates.
## Current checkpoint and work
**Advanced and Basic Ecaz future-battle Truthtrance — 29 October. Prototyped / Partial.**
The conditional native-leader question now supports Advanced games without
Ecaz or optional modules, and Basic Ecaz games. A Basic combined battle binds
the selected plan owner and opponent after the Occupy response; the nonleading
ally waits for its own later plan, a provisional interpretation. Ten related
files pass 75 cases, including real Advanced classic and Ixian battles and
four Basic Ecaz lead/cancellation paths. The Ecaz fixture stages physical
cards, a rival army and shipment budgets; typecheck, lint and build pass.
Advanced Ecaz, optional modules,
Semuta exchange and broader faction interactions remain open.
[Scope](TRUTHTRANCE_TURN_ACTIONS.md#conditional-next-battle-leader-question--28-october-2026).
**Basic expansion future-battle Truthtrance — 29 October. Prototyped / Partial.**
The conditional native-leader question is available in quiet Basic games with
expansion factions and decks, except Ecaz and optional modules. CHOAM Yes/No
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Basic Ecaz conditional battle answer — 29 October 2026
For the provisional first-later-battle Truthtrance question, a Basic combined
Ecaz battle counts for the player who submits its plan. Its selected lead and
opponent bind after the Occupy cancellation response; a nonleading ally waits
for its own later plan and otherwise expires. This is an online interpretation,
not a publisher ruling. [Scope](TRUTHTRANCE_TURN_ACTIONS.md#conditional-next-battle-leader-question--28-october-2026).
## Sequential Richese auction clocks — 29 October 2026
Fresh Once Around and normal Black Market lots use the trusted ten-second
+16 -9
View File
@@ -2,19 +2,20 @@
## Conditional next-battle leader question — 28 October 2026
**Prototyped / Partial.** In a quiet Basic Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window with
classic or expansion factions, no Ecaz and no optional modules, a player can ask whether another
**Prototyped / Partial.** In a quiet Storm, Spice Blow, Charity, Bidding,
Revival or Shipment and Movement window, a player can ask whether another
player will use a named native leader in that player's first later battle this
turn, if one occurs. A definite answer creates a waiting receipt; it does not
require a shipment, movement or battle. At the first actual battle involving
that player the receipt binds its real battle event and uses the native plan
require a shipment, movement or battle. At the first actual battle for which
that player submits a plan, the receipt binds its real battle event and uses the native plan
validator and guidance. If no battle occurs, the receipt expires when Battle
ends. An independently impossible answer is released by the existing battle
promise reconciliation. Another structured action answer for that respondent
is guarded while the conditional answer is active; broader promise composition,
Ecaz, optional modules and Advanced are not supported here. Ecaz's combined
battle may select a different lead after scheduling, so it needs its own binding
and expiry treatment before this question can be enabled in those games.
optional modules and Advanced Ecaz are not supported here. In Basic Ecaz's
combined battle, the selected lead and opponent bind only after the Occupy
cancellation response has resolved. The nonleading ally remains waiting for
its own later battle; this plan-owner interpretation is provisional.
Storm requires its original post-dial, pre-movement window; Spice Blow requires
the original pre-draw window with no Nexus or worm continuation. During these
windows, Charity, Bidding and Revival, definite answers use currently available native leaders or
@@ -28,13 +29,19 @@ provisional online interpretations, not specific publisher rulings. The
[Bidding continuation tests](../tests/truth-future-battle-bidding.test.ts) and
[Storm/Spice continuation tests](../tests/truth-future-battle-early.test.ts),
plus [Basic expansion tests](../tests/truth-future-battle-leader-expansions.test.ts),
[Basic Ecaz tests](../tests/truth-future-battle-leader-ecaz.test.ts) and
[Advanced tests](../tests/truth-future-battle-leader-advanced.test.ts),
exercise both answers and expiry without inventing a battle.
The Basic expansion widening has focused coverage of CHOAM's Yes and No in
actual battles and a Richese physical discard, Semuta pass and no-battle
expiry, plus all four minimal answer profiles. The Ecaz guard uses a staged
seat; it is not an Ecaz game journey. Semuta exchange, other expansion-faction
battle interactions, Advanced and optional modules remain unverified.
seat; it is not an Advanced Ecaz game journey. The Advanced tests cover a
classic paid-support battle and an Ixian battle. The Basic Ecaz lead and
allowed/canceled Occupy cases use genuine setup, real questions and battles,
with staged physical cards, rival forces and shipment budgets. Ten related
test files pass 75 cases. Semuta exchange, other expansion-faction battle
interactions, Advanced Ecaz and optional modules remain unverified.
## Printed flight and pre-leader cards — 27 October 2026
+4 -1
View File
@@ -36658,6 +36658,9 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
!Object.keys(b.plans).length, 'Resolve Occupy before native powers and plans.');
occupy.canceled = canceled;
ecazBattleProfile(g);
// The chosen plan owner and final Occupy force profile are now fixed.
// A nonleading ally keeps its waiting receipt for its own later battle.
bindFutureBattleLeaderQuestions(g);
createMentatQuestion(g);
beginStrongholdBattle(g);
log(g, canceled ? 'Karama canceled the combined dial: the selected lead now dials only their own forces; the other allied army contributes zero.' : 'Ecaz Occupy proceeds with its mandatory free contribution and the ally’s physical force pool.', { faction: 'ecaz', name: 'Occupy' });
@@ -46114,7 +46117,7 @@ function applyActionInner(
revealed: false,
traitorCalls: {},
};
bindFutureBattleLeaderQuestions(g);
if (!combined) bindFutureBattleLeaderQuestions(g);
if (combined) {
g.battle.ecazOccupy = { event: battleEvent, ecaz: ecaz!.id, ally: ally!.id, lead: null, canceled: false };
const opponent = [choice.attacker, choice.defender].find(id => id !== ecaz!.id && id !== ally!.id)!;
+3 -4
View File
@@ -11,11 +11,10 @@ export function futureBattleLeaderTiming(g: Pick<Game, 'phase' | 'stormPending'
}
/** Share the same quiet-window boundary between question validation and the
* projected editor. Basic expansion factions use the same native battle
* receipt except Ecaz, whose combined battle lead is chosen afterward. */
* projected editor. Advanced Ecaz remains outside the current preview. */
export function futureBattleLeaderWindow(g: Game): boolean {
return g.status === 'playing' && !g.advanced && futureBattleLeaderTiming(g) &&
!g.players.some(player => player.faction === 'ecaz') && !g.ecazOccupyPreview &&
return g.status === 'playing' && futureBattleLeaderTiming(g) &&
(!g.advanced || !g.players.some(player => player.faction === 'ecaz') && !g.ecazOccupyPreview) &&
!g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival &&
+1 -1
View File
@@ -3314,7 +3314,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine printed Karama shipment pricing and current-lot costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'In a quiet Basic Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. Storm must have a pending movement after the dials; Spice Blow must be before the draw. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question supports classic and expansion factions without Ecaz or optional modules and uses only currently available leaders for its answer.',
'In a supported quiet Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. Storm must have a pending movement after the dials; Spice Blow must be before the draw. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question supports Basic factions and Advanced without Ecaz, excluding optional modules. In a Basic Ecaz combined battle, only the selected lead and opponent bind; the nonleading ally waits for its own later battle. Answers use only currently available leaders.',
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'The supported bare Basic printed-Kull profile also permits these questions, including with Richese and Semuta. The answer remains bound while Kull or its counter is resolving. Voice, Prescience, Ixian cyborg strength and clean Guild income cancellations can use a proven continuation; other Karama uses remain guarded until their effects can be quoted without assuming hidden or foreign outcomes.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
+2 -2
View File
@@ -610,7 +610,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
v.turn === g.turn && typeof v.leader === 'string' &&
leaders(target.faction).some(leader => leader.id === v.leader) &&
futureBattleLeaderWindow(g),
'Choose a native leader in a quiet Basic Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window.');
'Choose a native leader in a supported quiet Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window.');
return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
}
if (v.kind === 'battlePlan') {
@@ -816,7 +816,7 @@ export function truthQuestionText(
if (q.kind === 'battlePlan')
return `In this battle in ${locationName(q.territory)}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
if (q.kind === 'futureBattleLeader')
return `If you fight a battle later this turn, will you use ${leaderName(q.leader)} in your first such battle?`;
return `If you submit a battle plan later this turn, will you use ${leaderName(q.leader)} in your first such battle?`;
const clause = (f: TruthFact): string => {
if (isKnowledgeFact(f)) return knowledgeFactText(f);
if (f.kind === 'noFieldValue') return noFieldValueFactText(f);
+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, []);