Enable future battle questions in Basic expansions
Verify and publish checkpoint container / container (push) Successful in 10m55s

This commit is contained in:
Quintine committed 2026-10-29 02:16:15 +11:00
1 parent 7fc6f1cd43
commit 0e52c25355
7 files changed
+151 -14

No files matched your search

+10
View File
@@ -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.
+12 -3
View File
@@ -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
+2 -5
View File
@@ -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) : [],
+12
View File
@@ -10,6 +10,18 @@ export function futureBattleLeaderTiming(g: Pick<Game, 'phase' | 'stormPending'
return [2, 3, 4, 5].includes(g.phase);
}
/** 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. */
export function futureBattleLeaderWindow(g: Game): boolean {
return g.status === 'playing' && !g.advanced && futureBattleLeaderTiming(g) &&
!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 &&
!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. */
+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 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.',
+2 -5
View File
@@ -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 };
}
@@ -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);
});