From 0e9209531aed81534d83f880fbe930f76302785c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:10:08 +1100 Subject: [PATCH] Support conditional Truthtrance before storm and spice --- docs/CURRENT_STATUS.md | 14 ++- docs/TRUTHTRANCE_TURN_ACTIONS.md | 9 +- game/engine.ts | 6 +- game/future-battle-leader.ts | 12 +++ game/reference.ts | 2 +- game/truthtrance.ts | 5 +- tests/truth-future-battle-early.test.ts | 134 ++++++++++++++++++++++++ 7 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 tests/truth-future-battle-early.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f6aa7aab..6775c51d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -38,6 +38,14 @@ the current group model deliberately caps membership at three. The combined earlier batch passed 189 cases; the shipment batch passed 58 cases. The latest focused funding, Nexus, shipment and Truthtrance union passed 61 cases. +**Conditional future-battle Truthtrance in Basic Storm and Spice Blow — +28 October. Prototyped / Partial.** A named-leader question can use the quiet +post-dial Storm window or the pre-draw Spice window. Real Storm casualties, +the Spice draw, first battle and no-battle expiry retain their native paths. +Active Storm resolution, Nexus and worm/Spice continuations remain guarded. +The related eight-file batch passes 48 cases, including legal replies from +all four existing AI profiles; this does not certify other combinations. + **Conditional future-battle Truthtrance in Basic Bidding — 28 October. Prototyped / Partial.** The named-leader question also works during a quiet ordinary auction. Its existing current-leader answer, first actual battle and @@ -128,7 +136,11 @@ extra visitor remains unproven. Gitea run 4 subsequently passed all 69 HTTP cases and published verified image `sha-8fb9f591060ba501ff9def8e32f8aa6e1786fc7d`. Run 5 for `e896570` also passed its 25 administrator checks, all 69 HTTP cases, restart/replacement and origin verification, and published both its -revision-specific image and `checkpoint`. Deployment remains unverified. +revision-specific image and `checkpoint`. Run 6 for `cea310b` succeeded and +published its revision tag and `checkpoint` with digest +`sha256:74340835931011be1d0be907c8db688719f05a1a1b949ba0633eff4c5fb55248`. +Run 7 was canceled before starting; run 8 for `e529cc2` is running. +Deployment remains unverified. Existing NAS deployment and its persistent storage remain untouched. **Semuta before Basic Moritani ally retention — 28 October. Prototyped / diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index 06dc55a4..286cdda5 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -2,7 +2,7 @@ ## Conditional next-battle leader question — 28 October 2026 -**Prototyped / Partial.** In a quiet Basic Charity, Bidding, Revival or Shipment and Movement window with +**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 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 @@ -13,7 +13,9 @@ 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. -During Charity, Bidding and Revival, definite answers use currently available native leaders or +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 Cheap Heroes; they do not forecast a future revival or spend its resources. The publisher's [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) @@ -21,7 +23,8 @@ permits game-related current-turn action questions and requires honoring an 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) +[Bidding continuation tests](../tests/truth-future-battle-bidding.test.ts) and +[Storm/Spice continuation tests](../tests/truth-future-battle-early.test.ts) exercise both answers and expiry without inventing a battle. ## Printed flight and pre-leader cards — 27 October 2026 diff --git a/game/engine.ts b/game/engine.ts index b18b3224..bd59ff7f 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, type FutureBattleLeaderReceipt } from './future-battle-leader'; + expireFutureBattleLeader, futureBattleLeaderTiming, type FutureBattleLeaderReceipt } from './future-battle-leader'; import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction, FactionDraftError, type FactionDraft } from './faction-draft'; import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap, @@ -31695,7 +31695,7 @@ function futureBattleLeaderTruthAnswers(g: Game, p: Player, q: FutureBattleLeade ...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []), ...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []), ]; - if (q.turn !== g.turn || q.target !== p.id || ![2, 3, 4, 5].includes(g.phase) || + if (q.turn !== g.turn || q.target !== p.id || !futureBattleLeaderTiming(g) || (g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === p.id && (row.status === 'waiting' || row.status === 'bound')) || otherPromises.some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) || @@ -45802,7 +45802,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 && [2, 3, 4, 5].includes(g.phase) && + 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 diff --git a/game/future-battle-leader.ts b/game/future-battle-leader.ts index 2641426e..4beed577 100644 --- a/game/future-battle-leader.ts +++ b/game/future-battle-leader.ts @@ -1,3 +1,15 @@ +import type { Game } from './engine'; + +/** Early questions use the native pre-resolution Storm or pre-draw Spice + * window. Other phases retain their existing quiet-interaction checks. */ +export function futureBattleLeaderTiming(g: Pick): boolean { + if (g.phase === 0) return Number.isInteger(g.stormPending) && !g.stormResolution; + if (g.phase === 1) return !g.spiceSequence && !g.spiceWindow && !g.spiceResolution && + !g.nexus && !g.summonedWorm && !g.wormRides.length; + return [2, 3, 4, 5].includes(g.phase); +} + /** 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 91b8a445..8f023213 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3305,7 +3305,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 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. 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 currently supports classic factions without 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.', '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.', 'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 9e62a335..22aaeabb 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,4 +1,5 @@ import { liveCharityPromises } from './charity-promises'; +import { futureBattleLeaderTiming } 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, @@ -602,11 +603,11 @@ 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 && [2, 3, 4, 5].includes(g.phase) && !g.expansions.length && + 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, - 'Choose a native leader in a quiet Basic Charity, Bidding, Revival or Shipment and Movement window.'); + '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 }; } if (v.kind === 'battlePlan') { diff --git a/tests/truth-future-battle-early.test.ts b/tests/truth-future-battle-early.test.ts new file mode 100644 index 00000000..e95ec446 --- /dev/null +++ b/tests/truth-future-battle-early.test.ts @@ -0,0 +1,134 @@ +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 { futureBattleLeaderTiming } from '../game/future-battle-leader'; +import { advanceTruthRevival, nextTruthRevival, truthRevivalClean, truthRevivalInventory, truthRevivalPlayer } from './fixture-truth-revival'; +import { + actFutureBattleCharity as act, askFutureBattleCharity as ask, + createFutureBattleCharityFixture, futureBattleCharityRecords as records, + reachFutureBattleCharityBattle as reachBattle, reachFutureBattleCharityWindow as reachMovement, +} from './fixture-truth-future-battle-charity'; + +/** Original turn-two Storm and its pre-Spice draw window: no played hand, + * army, phase or promise is installed to manufacture these opportunities. */ +function fixture(phase: 0 | 1) { + const source = createFutureBattleCharityFixture(); + const game = structuredClone(phase === 0 ? source.base.storm.before : source.base.storm.after); + assert.equal(game.phase, phase); + assert.equal(game.turn, source.game.turn); + assert.ok(truthRevivalClean(game)); + assert.ok(truthRevivalPlayer(game, source.asker).hand.some(card => card.id === source.truth)); + assert.deepEqual(truthRevivalInventory(game), source.originalCards); + assert.ok(viewGame(game, source.asker).truthFutureBattleLeaderTargets.includes(source.target)); + return { ...source, game }; +} + +function finishBattle(game: Game, f: ReturnType, answer: 'yes' | 'no') { + const plan = (leader: string): Action => ({type: 'battlePlan', leader, dial: 0, support: 0, weapon: null, defense: null}); + const before = structuredClone(game); + assert.throws(() => act(game, f.target, plan(answer === 'yes' ? f.alternative : f.leader))); + assert.deepEqual(game, before); + game = act(game, f.target, plan(answer === 'yes' ? f.leader : f.alternative)); + const opponent = game.battle!.attacker === f.target ? game.battle!.defender : game.battle!.attacker; + const other = truthRevivalPlayer(game, opponent).leaders.find(leader => + !leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt); + assert.ok(other); + game = act(game, opponent, plan(other.id)); + assert.ok(game.battle?.revealed); + assert.equal(records(game)[0].status, 'settled'); + return game; +} + +for (const phase of [0, 1] as const) { + const window = phase === 0 ? 'Storm' : 'Spice Blow'; + for (const answer of ['yes', 'no'] as const) { + void test(`Basic ${window} future-battle ${answer} preserves its native continuation and binds the later battle`, () => { + const f = fixture(phase); + const storm = f.game.storm, pending = f.game.stormPending; + const spiceDeck = structuredClone(f.game.spiceDeck); + const forces = structuredClone(truthRevivalPlayer(f.game, f.target).forces); + let game = act(ask(f), f.target, {type: 'truthAnswer', answer}); + assert.equal(game.phase, phase); assert.equal(game.storm, storm); assert.equal(game.stormPending, pending); + assert.deepEqual(game.spiceDeck, spiceDeck); + assert.deepEqual(truthRevivalPlayer(game, f.target).forces, forces); + assert.equal(records(game)[0].status, 'waiting'); + game = reachMovement(game, f); + assert.equal(truthRevivalPlayer(game, f.target).forces[f.base.location], undefined); + assert.equal(truthRevivalPlayer(game, f.target).tanks, 3); + assert.ok(game.spiceDeck.length < spiceDeck.length, 'The original Spice Blow must actually draw.'); + game = reachBattle(game, f); + assert.equal(records(game)[0].status, 'bound'); + game = finishBattle(game, f, answer); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); + assert.deepEqual(truthRevivalInventory(game), f.originalCards); + }); + } + + void test(`Basic ${window} conditional leader answer expires without a later battle`, () => { + const f = fixture(phase); + let game = act(ask(f), f.target, {type: 'truthAnswer', answer: 'yes'}); + game = reachMovement(game, f); + assert.ok(Object.values(truthRevivalPlayer(game, f.target).forces).every(amount => amount === 0)); + for (let count = 0; count < 1200 && game.turn === f.game.turn && game.phase <= 6 && game.status === 'playing'; count++) { + if (game.phase === 5 && game.active && truthRevivalClean(game)) { + game = act(game, game.active, {type: 'endMovement'}); continue; + } + const next = nextTruthRevival(game); assert.ok(next); + game = act(game, next.actor, next.action); + } + assert.equal(records(game)[0].status, 'expired'); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); + }); + + void test(`each AI difficulty can legally answer the Basic ${window} question`, () => { + for (const difficulty of DIFFICULTIES) { + const f = fixture(phase); + const asked = ask(f); + truthRevivalPlayer(asked, f.target).bot = difficulty; + const answer = botActions(viewGame(asked, f.target)).find(action => action.type === 'truthAnswer'); + assert.ok(answer); + const game = act(asked, f.target, answer); + assert.equal(records(game)[0].status, 'waiting'); + } + }); +} + +void test('early timing rejects controlled unsupported Storm and Spice parents', () => { + const cases: {phase: 0 | 1; name: string; set: (game: Game) => void}[] = [ + {phase: 0, name: 'unresolved Storm dials', set: game => { game.stormPending = null; }}, + {phase: 0, name: 'Storm resolution', set: game => { + game.stormResolution = {from: game.storm, distance: 1, traversed: 0, pending: []}; + }}, + {phase: 1, name: 'Spice resolution', set: game => { game.spiceResolution = {skipped: []}; }}, + {phase: 1, name: 'Spice sequence', set: game => { game.spiceSequence = {pile: 0, skipped: []}; }}, + {phase: 1, name: 'active Spice window', set: game => { + game.spiceWindow = {territory: 'red_chasm', sector: 7, amount: 1, harvested: false}; + }}, + {phase: 1, name: 'Nexus', set: game => { game.nexus = true; }}, + {phase: 1, name: 'worm ride', set: game => { game.wormRides = ['red_chasm']; }}, + ]; + for (const entry of cases) { + const f = fixture(entry.phase); + // Controlled parent probes: these fields represent continuations that are + // outside the permitted quiet window, not an original full-game journey. + entry.set(f.game); + assert.equal(futureBattleLeaderTiming(f.game), false, entry.name); + } +}); + +void test('real pre-dial Storm and active Spice Blow hide targets and reject the early question', () => { + const source = fixture(0); + const storm = advanceTruthRevival(structuredClone(source.base.continuation.before), state => + state.turn === source.game.turn && state.phase === 0 && state.stormPending === null && truthRevivalClean(state)); + const spiceSource = fixture(1); + const spice = advanceTruthRevival(structuredClone(spiceSource.game), state => + state.turn === spiceSource.game.turn && state.phase === 1 && !!state.spiceWindow && truthRevivalClean(state)); + 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.deepEqual(game, before); + } +});