Enable future battle questions in Basic expansions
Verify and publish checkpoint container / container (push) Successful in 10m55s
Verify and publish checkpoint container / container (push) Successful in 10m55s
This commit is contained in:
1 parent
7fc6f1cd43
commit
0e52c25355
7 files changed
+151
-14
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) : [],
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user