diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c8a3d322..c1dd6aae 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**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 +answers bind actual battles; a Richese answer survives the physical discard +and Semuta pass, then expires if no battle occurs. Ecaz's late combined-battle +lead, Semuta exchange, other expansion interactions and Advanced remain open. +Seven focused files pass 33 cases, including four minimal bot answers and a +staged Ecaz guard probe; typecheck, lint and build pass. +[Scope](TRUTHTRANCE_TURN_ACTIONS.md#conditional-next-battle-leader-question--28-october-2026). + **Sequential Richese bidding clock — 29 October. Prototyped / Partial.** Fresh Once Around and normal Black Market lots use the trusted ten-second countdown and native pass, with supported binding Truthtrance bids honored. diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index 286cdda5..207f2ef9 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -3,7 +3,7 @@ ## 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 factions and no optional modules, a player can ask whether another +classic or expansion factions, no Ecaz and no optional modules, 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 @@ -12,7 +12,9 @@ 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, -expansion factions, optional modules and Advanced are not supported here. +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. 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 @@ -24,9 +26,16 @@ answer while possible. The first-battle binding and no-opportunity expiry are provisional online interpretations, not specific publisher rulings. The [native runtime tests](../tests/truth-future-battle-leader-runtime.test.ts) and [Bidding continuation tests](../tests/truth-future-battle-bidding.test.ts) and -[Storm/Spice continuation tests](../tests/truth-future-battle-early.test.ts) +[Storm/Spice continuation tests](../tests/truth-future-battle-early.test.ts), +plus [Basic expansion tests](../tests/truth-future-battle-leader-expansions.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. + ## Printed flight and pre-leader cards — 27 October 2026 **Prototyped / Partial.** `cardPlay/ornithopter` counts the first accepted diff --git a/game/engine.ts b/game/engine.ts index 90d33053..e8a26df4 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,7 +1,7 @@ import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete, addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers'; import { createFutureBattleLeader, bindFutureBattleLeader, settleFutureBattleLeader, releaseFutureBattleLeader, - expireFutureBattleLeader, futureBattleLeaderTiming, type FutureBattleLeaderReceipt } from './future-battle-leader'; + expireFutureBattleLeader, futureBattleLeaderTiming, futureBattleLeaderWindow, type FutureBattleLeaderReceipt } from './future-battle-leader'; import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction, FactionDraftError, type FactionDraft } from './faction-draft'; import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap, @@ -46800,10 +46800,7 @@ export function viewGame(state: Game, id: string, now?: number) { truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader' ? futureBattleLeaderTruthAnswers(g, me, g.truthtrance.question) : null, - truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && futureBattleLeaderTiming(g) && - !g.expansions.length && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds && - !g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response && - !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard + truthFutureBattleLeaderTargets: futureBattleLeaderWindow(g) ? g.players.filter(player => player.id !== id && !(g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === player.id && (row.status === 'waiting' || row.status === 'bound'))) .map(player => player.id) : [], diff --git a/game/future-battle-leader.ts b/game/future-battle-leader.ts index 4beed577..551300d0 100644 --- a/game/future-battle-leader.ts +++ b/game/future-battle-leader.ts @@ -10,6 +10,18 @@ export function futureBattleLeaderTiming(g: Pick 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 && + !g.pendingKarama && !g.pendingTreacheryDiscard; +} + /** A conditional commitment for the respondent's first later native battle. * Waiting does not require a battle to occur. The engine owns plan legality, * answer feasibility and the decision that an obligation became impossible. */ diff --git a/game/reference.ts b/game/reference.ts index 0dbd460c..d41d96fa 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 currently supports classic factions without optional modules and uses only currently available leaders for its answer.', + '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.', '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 77dea780..07557ee4 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,5 +1,5 @@ import { liveCharityPromises } from './charity-promises'; -import { futureBattleLeaderTiming } from './future-battle-leader'; +import { futureBattleLeaderWindow } from './future-battle-leader'; import { createSaphoTruthOrder, passSaphoTruthOrder, SaphoTruthOrderError, type SaphoTruthOrderFrame } from './sapho-truth-order'; import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises'; import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, @@ -609,10 +609,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' && v.turn === g.turn && typeof v.leader === 'string' && leaders(target.faction).some(leader => leader.id === v.leader) && - g.status === 'playing' && !g.advanced && futureBattleLeaderTiming(g) && !g.expansions.length && - !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds && - !g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && - !g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard, + futureBattleLeaderWindow(g), 'Choose a native leader in a quiet Basic Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window.'); return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader }; } diff --git a/tests/truth-future-battle-leader-expansions.test.ts b/tests/truth-future-battle-leader-expansions.test.ts new file mode 100644 index 00000000..1639f917 --- /dev/null +++ b/tests/truth-future-battle-leader-expansions.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createTruthMovementChoamFixture } from './fixture-truth-movement-choam'; +import { + actFutureBattleLeader as act, advanceFutureBattleLeader as advance, + futureBattleLeaderClean as clean, futureBattleLeaderRecords as records, +} from './fixture-truth-future-battle-leader'; +import { + createTruthtranceRichesePromisesFixture, passTruthRicheseFreshDiscard, + finishTruthtranceRichesePromises, truthtranceRichesePromisesInventory, +} from './fixture-truthtrance-richese-promises'; + +function ask(game: Game, asker: string, card: string, target: string, leader: string): Game { + assert.ok(viewGame(game, asker).truthFutureBattleLeaderTargets.includes(target)); + game = act(game, asker, { type: 'card', card }); + 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'); + return act(game, asker, { type: 'truthAsk', question: { + kind: 'futureBattleLeader', target, turn: game.turn, leader, + } } as unknown as Action); +} + +for (const answer of ['yes', 'no'] as const) { + void test(`Basic CHOAM ${answer} binds its first real battle and constrains its native leader`, () => { + const f = createTruthMovementChoamFixture(); + const leader = f.beforeShipment.players.find(player => player.id === f.target)!.leaders[0].id; + const other = f.beforeShipment.players.find(player => player.id === f.target)!.leaders[1].id; + let game = ask(f.beforeShipment, f.asker, f.truth, f.target, leader); + assert.ok(viewGame(game, f.target).truthFutureBattleLeaderAnswers?.includes(answer)); + game = act(game, f.target, { type: 'truthAnswer', answer }); + assert.equal(records(game)[0].status, 'waiting'); + game = act(game, f.target, { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }); + game = advance(game, clean); + game = act(game, f.target, { type: 'endMovement' }); + game = advance(game, state => clean(state) && state.phase === 5 && state.active === f.asker); + game = act(game, f.asker, { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }); + game = advance(game, clean); + game = act(game, f.asker, { type: 'endMovement' }); + game = advance(game, state => clean(state) && state.phase === 6 && !!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 }); + 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 plan = (id: string): Action => ({ type: 'battlePlan', leader: id, dial: 0, support: 0, weapon: null, defense: null }); + const invalid = plan(answer === 'yes' ? other : leader); + const before = structuredClone(game); + assert.throws(() => act(game, f.target, invalid)); + assert.deepEqual(game, before); + game = act(game, f.target, plan(answer === 'yes' ? leader : other)); + const emperorLeader = game.players.find(player => player.id === f.asker)!.leaders[0].id; + game = act(game, f.asker, plan(emperorLeader)); + assert.equal(records(game)[0].status, 'settled'); + }); +} + +void test('Basic Richese answer survives physical Truthtrance discard and Semuta continuation, then expires without a battle', () => { + const f = createTruthtranceRichesePromisesFixture(); + const leader = f.game.players.find(player => player.id === f.target)!.leaders[0].id; + let game = ask(f.game, f.asker, f.truth, f.target, leader); + assert.ok(viewGame(game, f.target).truthFutureBattleLeaderAnswers?.includes('yes')); + for (const difficulty of DIFFICULTIES) { + const copy = structuredClone(game); + copy.players.find(player => player.id === f.target)!.bot = difficulty; + const reply = botActions(viewGame(copy, f.target)).find(action => action.type === 'truthAnswer'); + assert.ok(reply, difficulty); + const answered = act(copy, f.target, reply); + assert.equal(records(answered)[0].status, 'waiting'); + } + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + assert.equal(records(game)[0].status, 'waiting'); + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + game = passTruthRicheseFreshDiscard(game); + assert.equal(records(game)[0].status, 'waiting'); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); + game = finishTruthtranceRichesePromises(game); + assert.equal(game.battle, null); + assert.equal(records(game)[0].status, 'expired'); + assert.deepEqual(truthtranceRichesePromisesInventory(game), f.originalCards); +}); + +void test('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.expansions.push('ecaz'); + game.players.push(newPlayer('ecaz-guard', 'Ecaz', 'ecaz')); + assert.deepEqual(viewGame(game, f.asker).truthFutureBattleLeaderTargets, []); + const leader = game.players.find(player => player.id === f.target)!.leaders[0].id; + 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'); + const before = structuredClone(game); + assert.throws(() => act(game, f.asker, { type: 'truthAsk', question: { + kind: 'futureBattleLeader', target: f.target, turn: game.turn, leader, + } } as unknown as Action)); + assert.deepEqual(game, before); +});