From 50703664cfc7a3f66f4d30bc4da079b67a9f370f Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:21:12 +1100 Subject: [PATCH] Extend conditional battle questions across previews --- components/truth-future-battle-leader.tsx | 8 +- docs/CURRENT_STATUS.md | 12 ++ docs/RULE_DECISIONS.md | 8 + docs/TRUTHTRANCE_TURN_ACTIONS.md | 25 +-- game/engine.ts | 5 +- game/future-battle-leader.ts | 7 +- game/reference.ts | 2 +- game/truthtrance.ts | 4 +- tests/truth-future-battle-early.test.ts | 2 +- ...ruth-future-battle-leader-advanced.test.ts | 140 +++++++++++++++++ ...uth-future-battle-leader-controls.test.tsx | 2 +- tests/truth-future-battle-leader-ecaz.test.ts | 142 ++++++++++++++++++ ...th-future-battle-leader-expansions.test.ts | 3 +- 13 files changed, 336 insertions(+), 24 deletions(-) create mode 100644 tests/truth-future-battle-leader-advanced.test.ts create mode 100644 tests/truth-future-battle-leader-ecaz.test.ts diff --git a/components/truth-future-battle-leader.tsx b/components/truth-future-battle-leader.tsx index f788fec8..f383edab 100644 --- a/components/truth-future-battle-leader.tsx +++ b/components/truth-future-battle-leader.tsx @@ -37,9 +37,9 @@ export function TruthFutureBattleLeader({ game, target, leader, available, busy, }
- 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?
-

If you do not fight a later battle this turn, there is no opportunity to use that leader.

+

If you submit no later battle plan this turn, there is no opportunity to use that leader.

; @@ -53,8 +53,8 @@ export function FutureBattleLeaderPromises({ game }: {game: GameView}) { return
{promises.map((row, 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.'}

)}
; } diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c1dd6aae..879bd7af 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 28be64fa..f1f7ee7b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index 207f2ef9..660c085e 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -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 diff --git a/game/engine.ts b/game/engine.ts index e8a26df4..eac7d0b6 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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)!; diff --git a/game/future-battle-leader.ts b/game/future-battle-leader.ts index 551300d0..b74df5fe 100644 --- a/game/future-battle-leader.ts +++ b/game/future-battle-leader.ts @@ -11,11 +11,10 @@ export function futureBattleLeaderTiming(g: Pick 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 && diff --git a/game/reference.ts b/game/reference.ts index d41d96fa..f9d76da4 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 07557ee4..b7d8c923 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -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); diff --git a/tests/truth-future-battle-early.test.ts b/tests/truth-future-battle-early.test.ts index e95ec446..49d65eee 100644 --- a/tests/truth-future-battle-early.test.ts +++ b/tests/truth-future-battle-early.test.ts @@ -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); } }); diff --git a/tests/truth-future-battle-leader-advanced.test.ts b/tests/truth-future-battle-leader-advanced.test.ts new file mode 100644 index 00000000..a2be555c --- /dev/null +++ b/tests/truth-future-battle-leader-advanced.test.ts @@ -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; + +/** 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); +}); diff --git a/tests/truth-future-battle-leader-controls.test.tsx b/tests/truth-future-battle-leader-controls.test.tsx index c4d68d1a..77b31a83 100644 --- a/tests/truth-future-battle-leader-controls.test.tsx +++ b/tests/truth-future-battle-leader-controls.test.tsx @@ -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)); diff --git a/tests/truth-future-battle-leader-ecaz.test.ts b/tests/truth-future-battle-leader-ecaz.test.ts new file mode 100644 index 00000000..b30cad5d --- /dev/null +++ b/tests/truth-future-battle-leader-ecaz.test.ts @@ -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 = {}; + 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'); + }); diff --git a/tests/truth-future-battle-leader-expansions.test.ts b/tests/truth-future-battle-leader-expansions.test.ts index 1639f917..a5d9e102 100644 --- a/tests/truth-future-battle-leader-expansions.test.ts +++ b/tests/truth-future-battle-leader-expansions.test.ts @@ -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, []);