Prototype conditional future battle Truthtrance
Verify and publish checkpoint container / container (push) Failing after 11m11s
Verify and publish checkpoint container / container (push) Failing after 11m11s
This commit is contained in:
1 parent
71009d73e3
commit
02695aea7c
15 files changed
+721
-8
No files matched your search
@@ -1460,6 +1460,10 @@ function policyActions(g: GameView): Action[] {
|
||||
'unknown');
|
||||
return [{ type: 'truthAnswer', answer }];
|
||||
}
|
||||
if (w.question.kind === 'futureBattleLeader') {
|
||||
const choices = g.truthFutureBattleLeaderAnswers ?? ['unknown'];
|
||||
return [{ type: 'truthAnswer', answer: choices[0] }];
|
||||
}
|
||||
if (w.question.kind === 'battlePlan') {
|
||||
const choices = g.truthBattleAnswers ?? ['unknown'];
|
||||
const claim = w.question.claim;
|
||||
|
||||
@@ -1,5 +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';
|
||||
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
|
||||
FactionDraftError, type FactionDraft } from './faction-draft';
|
||||
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
||||
@@ -682,6 +684,7 @@ import {
|
||||
type TruthQueueEntry,
|
||||
type TruthRecord,
|
||||
type TruthAnswer,
|
||||
type TruthQuestion,
|
||||
type MixedMovementClaim,
|
||||
} from './truthtrance';
|
||||
import type { MixedShipmentExpression } from './mixed-shipment-question';
|
||||
@@ -2319,6 +2322,7 @@ export type Game = {
|
||||
};
|
||||
truthtrance?: TruthWindow | null;
|
||||
truthHistory?: TruthRecord[];
|
||||
futureBattleLeaderPromises?: FutureBattleLeaderReceipt[];
|
||||
travelPromises?: TravelPromise[];
|
||||
turnActionPromises?: TurnActionPromise[];
|
||||
/** Actual accepted printed-role uses; old games need no baseline retrofit. */
|
||||
@@ -7550,6 +7554,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
|
||||
...(['travel', 'turnActions'].includes(next.record.question.kind) ? { movementExpressionPromises: g.movementExpressionPromises } : {}),
|
||||
...(g.turnActionPromises ? { turnActionPromises: g.turnActionPromises, bribePromises: g.bribePromises,
|
||||
bribeExpressionPromises: g.bribeExpressionPromises, bribePromiseSequence: g.bribePromiseSequence } : {}),
|
||||
...(next.record.question.kind === 'futureBattleLeader' ? {futureBattleLeaderPromises: g.futureBattleLeaderPromises} : {}),
|
||||
...(next.record.question.kind === 'mixedTraitorCall' ? { mixedTraitorQuestions: g.mixedTraitorQuestions } : {}),
|
||||
...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}),
|
||||
...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? {
|
||||
@@ -8622,6 +8627,13 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
}
|
||||
const q = record.question,
|
||||
promise = c.promise;
|
||||
if (q.kind === 'futureBattleLeader') {
|
||||
const binding = g.futureBattleLeaderPromises?.at(-1);
|
||||
requireRule(binding && binding.turn === g.turn && binding.asker === record.asker &&
|
||||
binding.player === q.target && binding.leader === q.leader &&
|
||||
binding.answer === (record.answer === 'yes') && binding.status === 'waiting' && !binding.battle,
|
||||
'The completed Truthtrance lost its conditional battle answer.');
|
||||
}
|
||||
const expectedShipment = q.kind === 'shipment' || q.kind === 'mixedShipment';
|
||||
const expectedBattle =
|
||||
q.kind === 'battlePlan' &&
|
||||
@@ -31674,6 +31686,61 @@ function battleTruthAnswers(
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
type FutureBattleLeaderQuestion = Extract<TruthQuestion, {kind: 'futureBattleLeader'}>;
|
||||
function futureBattleLeaderTruthAnswers(g: Game, p: Player, q: FutureBattleLeaderQuestion): TruthAnswer[] {
|
||||
const otherPromises: {player: string; turn?: number; fulfilled?: boolean; released?: boolean}[] = [
|
||||
...(g.travelPromises ?? []), ...(g.shipmentPromises ?? []), ...(g.movementPromises ?? []),
|
||||
...(g.movementExpressionPromises ?? []), ...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? []),
|
||||
...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? []), ...(g.revivalExpressionPromises ?? []),
|
||||
...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
|
||||
...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
|
||||
];
|
||||
if (q.turn !== g.turn || q.target !== p.id || g.phase !== 5 ||
|
||||
(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) ||
|
||||
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length) return ['unknown'];
|
||||
const available = p.leaders.filter(leader => nativeAvailable(leader) && !leader.usedAt && leader.faction === p.faction);
|
||||
const result: TruthAnswer[] = [];
|
||||
if (available.some(leader => leader.id === q.leader)) result.push('yes');
|
||||
if (available.some(leader => leader.id !== q.leader) || p.hand.some(card => card.kind === 'hero')) result.push('no');
|
||||
return result.length ? result : ['unknown'];
|
||||
}
|
||||
function bindFutureBattleLeaderTruth(g: Game, p: Player, q: FutureBattleLeaderQuestion, answer: boolean, asker: string) {
|
||||
requireRule(futureBattleLeaderTruthAnswers(g, p, q).includes(answer ? 'yes' : 'no'),
|
||||
'Choose an answer supported by your own available leaders.');
|
||||
(g.futureBattleLeaderPromises ??= []).push(createFutureBattleLeader(
|
||||
{ turn: g.turn, asker, player: p.id, leader: q.leader, answer }, leaders(p.faction).map(leader => leader.id)));
|
||||
}
|
||||
function bindFutureBattleLeaderQuestions(g: Game) {
|
||||
const battle = g.battle;
|
||||
if (!battle?.event || !g.futureBattleLeaderPromises) return;
|
||||
g.futureBattleLeaderPromises = g.futureBattleLeaderPromises.map(receipt => {
|
||||
const bound = bindFutureBattleLeader(receipt, {turn: g.turn, event: battle.event!, attacker: battle.attacker,
|
||||
defender: battle.defender, native: true, plansOpen: !battle.revealed});
|
||||
if (bound !== receipt) {
|
||||
const claim: PlanClaim = {kind: 'leader', leader: bound.leader};
|
||||
bindBattleTruth(g, getPlayer(g, bound.player), claim, bound.answer, bound.asker);
|
||||
}
|
||||
return bound;
|
||||
});
|
||||
}
|
||||
function reconcileFutureBattleLeaderQuestions(g: Game) {
|
||||
const battle = g.battle;
|
||||
if (!g.futureBattleLeaderPromises) return;
|
||||
g.futureBattleLeaderPromises = g.futureBattleLeaderPromises.map(receipt => {
|
||||
if (receipt.status === 'waiting' && (g.turn > receipt.turn ||
|
||||
receipt.turn === g.turn && (g.phase > 6 || g.status !== 'playing')))
|
||||
return expireFutureBattleLeader(receipt, {turn: receipt.turn, battlePhaseEnded: true});
|
||||
if (receipt.status !== 'bound' || !battle || battle.event !== receipt.battle) return receipt;
|
||||
const bound = battle.truthPromises?.find(row => row.player === receipt.player && row.asker === receipt.asker &&
|
||||
row.answer === receipt.answer && row.claim.kind === 'leader' && row.claim.leader === receipt.leader);
|
||||
if (bound?.released) return releaseFutureBattleLeader(receipt, {turn: receipt.turn, battle: receipt.battle});
|
||||
if (battle.revealed && bound) return settleFutureBattleLeader(receipt, {turn: receipt.turn,
|
||||
battle: receipt.battle!, player: receipt.player, revealed: true, leader: battle.plans[receipt.player].leader});
|
||||
return receipt;
|
||||
});
|
||||
}
|
||||
function bindBattleTruth(
|
||||
g: Game,
|
||||
p: Player,
|
||||
@@ -40911,6 +40978,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
finishLeaderSkillCustody(g, state);
|
||||
leaderSkillsIntegrity(g);
|
||||
reconcileBattlePromises(g, { actor: id, action });
|
||||
reconcileFutureBattleLeaderQuestions(g);
|
||||
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcilePhaseBids(g, { actor: id, action });
|
||||
@@ -41575,6 +41643,8 @@ function applyActionInner(
|
||||
bindMixedBattle: bindMixedBattleTruth,
|
||||
battleAnswers: battleTruthAnswers,
|
||||
bindBattle: bindBattleTruth,
|
||||
futureBattleLeaderAnswers: futureBattleLeaderTruthAnswers,
|
||||
bindFutureBattleLeader: bindFutureBattleLeaderTruth,
|
||||
cardBlock: transferCardBlock,
|
||||
})
|
||||
)
|
||||
@@ -45038,6 +45108,7 @@ function applyActionInner(
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
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)!;
|
||||
@@ -45200,6 +45271,7 @@ function applyActionInner(
|
||||
b.revealed = true;
|
||||
recordTurnActionRevealedPlans(g);
|
||||
reconcileMixedBattleQuestions(g);
|
||||
reconcileFutureBattleLeaderQuestions(g);
|
||||
for (const plan of Object.values(b.plans)) {
|
||||
const l = g.players
|
||||
.flatMap((p) => p.leaders)
|
||||
@@ -45717,6 +45789,17 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
g.truthtrance.question.kind === 'battlePlan'
|
||||
? battleTruthAnswers(g, me, g.truthtrance.question.claim)
|
||||
: null,
|
||||
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 && g.phase === 5 &&
|
||||
!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.pendingKarama && !g.pendingTreacheryDiscard
|
||||
? 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) : [],
|
||||
futureBattleLeaderPromises: (g.futureBattleLeaderPromises ?? []).filter(row => row.player === id),
|
||||
bidPromiseLot: (() => { const lot = bidOpportunity(g); return lot ? { turn: lot.turn, lot: lot.lot } : null; })(),
|
||||
karamaAuctionCards: quotedAuctionKaramaCards(g, me),
|
||||
karamaShipmentCards: quotedShipmentKaramaCards(g, me),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/** 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. */
|
||||
export type FutureBattleLeaderReceipt = {
|
||||
turn: number;
|
||||
asker: string;
|
||||
player: string;
|
||||
leader: string;
|
||||
answer: boolean;
|
||||
battle?: string;
|
||||
status: 'waiting' | 'bound' | 'settled' | 'expired' | 'released';
|
||||
};
|
||||
|
||||
export type FutureBattleLeaderSource = {
|
||||
turn: number;
|
||||
event: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
native: boolean;
|
||||
plansOpen: boolean;
|
||||
};
|
||||
|
||||
export class FutureBattleLeaderError extends Error {}
|
||||
|
||||
function check(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new FutureBattleLeaderError(message);
|
||||
}
|
||||
|
||||
function validId(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length <= 256 && value.trim() === value && value.length > 0;
|
||||
}
|
||||
|
||||
function validTurn(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
function validateReceipt(receipt: FutureBattleLeaderReceipt): void {
|
||||
check(!!receipt && typeof receipt === 'object' && !Array.isArray(receipt), 'Invalid future battle receipt.');
|
||||
check(validTurn(receipt.turn) && validId(receipt.asker) && validId(receipt.player) &&
|
||||
receipt.asker !== receipt.player && validId(receipt.leader) && typeof receipt.answer === 'boolean',
|
||||
'Invalid future battle question.');
|
||||
check(['waiting', 'bound', 'settled', 'expired', 'released'].includes(receipt.status),
|
||||
'Invalid future battle status.');
|
||||
check(receipt.battle === undefined || validId(receipt.battle), 'Invalid future battle identity.');
|
||||
check(receipt.status !== 'waiting' && receipt.status !== 'expired' || receipt.battle === undefined,
|
||||
'A waiting or expired question cannot name a battle.');
|
||||
check(receipt.status !== 'bound' && receipt.status !== 'settled' || receipt.battle !== undefined,
|
||||
'A bound or settled question must name its battle.');
|
||||
}
|
||||
|
||||
/** Caller supplies the respondent's public, native leader identities; no hand is inspected. */
|
||||
export function createFutureBattleLeader(
|
||||
question: Pick<FutureBattleLeaderReceipt, 'turn' | 'asker' | 'player' | 'leader' | 'answer'>,
|
||||
nativeLeaders: readonly string[],
|
||||
): FutureBattleLeaderReceipt {
|
||||
check(!!question && typeof question === 'object', 'Invalid future battle question.');
|
||||
const receipt: FutureBattleLeaderReceipt = { turn: question.turn, asker: question.asker,
|
||||
player: question.player, leader: question.leader, answer: question.answer, status: 'waiting' };
|
||||
validateReceipt(receipt);
|
||||
check(Array.isArray(nativeLeaders) && nativeLeaders.every(validId) &&
|
||||
new Set(nativeLeaders).size === nativeLeaders.length && nativeLeaders.includes(receipt.leader),
|
||||
'Choose one of the respondent’s native leader identities.');
|
||||
return receipt;
|
||||
}
|
||||
|
||||
/** Observe a real battle; unrelated battles and nonmatching turns leave it waiting. */
|
||||
export function bindFutureBattleLeader(
|
||||
receipt: FutureBattleLeaderReceipt,
|
||||
source: FutureBattleLeaderSource,
|
||||
): FutureBattleLeaderReceipt {
|
||||
validateReceipt(receipt);
|
||||
check(!!source && typeof source === 'object' && validTurn(source.turn) && validId(source.event) &&
|
||||
validId(source.attacker) && validId(source.defender) && source.attacker !== source.defender &&
|
||||
typeof source.native === 'boolean' && typeof source.plansOpen === 'boolean',
|
||||
'Invalid observed battle.');
|
||||
if (receipt.status !== 'waiting' || receipt.turn !== source.turn || !source.native ||
|
||||
(receipt.player !== source.attacker && receipt.player !== source.defender))
|
||||
return receipt;
|
||||
check(source.plansOpen, 'The respondent’s first qualifying battle is already past plan preparation.');
|
||||
return { ...receipt, battle: source.event, status: 'bound' };
|
||||
}
|
||||
|
||||
/** Only the actual revealed respondent plan can settle the linked receipt. */
|
||||
export function settleFutureBattleLeader(
|
||||
receipt: FutureBattleLeaderReceipt,
|
||||
observed: { turn: number; battle: string; player: string; revealed: boolean; leader: string | null },
|
||||
): FutureBattleLeaderReceipt {
|
||||
validateReceipt(receipt);
|
||||
check(!!observed && typeof observed === 'object' && validTurn(observed.turn) && validId(observed.battle) &&
|
||||
validId(observed.player) && typeof observed.revealed === 'boolean' &&
|
||||
(observed.leader === null || validId(observed.leader)), 'Invalid revealed battle plan.');
|
||||
check(receipt.turn === observed.turn && receipt.battle === observed.battle && receipt.player === observed.player,
|
||||
'The revealed plan does not belong to this question.');
|
||||
check((receipt.status === 'bound' || receipt.status === 'settled') && observed.revealed,
|
||||
'Only an actual revealed bound plan can settle this question.');
|
||||
check((observed.leader === receipt.leader) === receipt.answer,
|
||||
'The revealed plan contradicts the active future battle answer.');
|
||||
if (receipt.status === 'settled') return receipt;
|
||||
return { ...receipt, status: 'settled' };
|
||||
}
|
||||
|
||||
/** The caller must independently establish impossibility before invoking this. */
|
||||
export function releaseFutureBattleLeader(
|
||||
receipt: FutureBattleLeaderReceipt,
|
||||
source: { turn: number; battle?: string },
|
||||
): FutureBattleLeaderReceipt {
|
||||
validateReceipt(receipt);
|
||||
check(!!source && typeof source === 'object' && validTurn(source.turn) &&
|
||||
(source.battle === undefined || validId(source.battle)) && source.turn === receipt.turn &&
|
||||
source.battle === receipt.battle, 'The release does not belong to this question.');
|
||||
if (receipt.status === 'released') return receipt;
|
||||
check(receipt.status === 'waiting' || receipt.status === 'bound', 'This question cannot be released.');
|
||||
return { ...receipt, status: 'released' };
|
||||
}
|
||||
|
||||
/** Only unbound questions expire when their original Battle phase has ended. */
|
||||
export function expireFutureBattleLeader(
|
||||
receipt: FutureBattleLeaderReceipt,
|
||||
source: { turn: number; battlePhaseEnded: boolean },
|
||||
): FutureBattleLeaderReceipt {
|
||||
validateReceipt(receipt);
|
||||
check(!!source && typeof source === 'object' && validTurn(source.turn) &&
|
||||
typeof source.battlePhaseEnded === 'boolean' && source.turn === receipt.turn,
|
||||
'The phase end does not belong to this question.');
|
||||
if (!source.battlePhaseEnded || receipt.status !== 'waiting') return receipt;
|
||||
return { ...receipt, status: 'expired' };
|
||||
}
|
||||
+2
-1
@@ -3305,6 +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 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.',
|
||||
'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.',
|
||||
@@ -3341,7 +3342,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse phase-opening Amal with native payment order, printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola/Box/Distrans/Sapho, accepted Ornithopter flights, native Residual Poison and completed Semuta exchanges, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide, with a separate conditional future-battle leader editor and reminder.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truth-future-battle-leader.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-future-battle-leader-controls.test.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed roles, exact fresh Semuta controls and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/fixture-truth-flight-poison.ts', 'tests/truth-flight-poison-runtime.test.ts', 'tests/fixture-truth-semuta-play.ts', 'tests/truth-semuta-play-runtime.test.ts', 'tests/semuta-reaction-controls.test.tsx', 'tests/fixture-truth-transfer-order-play.ts', 'tests/truth-transfer-order-play-runtime.test.ts', 'tests/fixture-distrans-prebid.ts', 'tests/fixture-sapho-started-movement.ts', 'tests/fixture-truth-box-play.ts', 'tests/truth-box-play-runtime.test.ts', 'tests/truth-opaque-card-plan.test.ts', 'tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', 'tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
|
||||
|
||||
+27
-1
@@ -117,6 +117,7 @@ export type TruthQuestion =
|
||||
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
||||
| { kind: 'mixedShipment'; target: string; mixed: MixedShipmentExpression }
|
||||
| { kind: 'battlePlan'; target: string; territory: string; claim: PlanClaim }
|
||||
| { kind: 'futureBattleLeader'; target: string; turn: number; leader: string }
|
||||
| { kind: 'fact'; target: string; fact: TruthFact }
|
||||
| {
|
||||
kind: 'freeform';
|
||||
@@ -269,6 +270,9 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
if (v.kind !== 'charity' && v.kind !== 'fact' && v.kind !== 'freeform' &&
|
||||
g.charityPromises?.length && liveCharityPromises(g.charityPromises,v.target as string,g.turn).length)
|
||||
throw new TruthError('Finish this player’s current Charity answer before binding another action question.');
|
||||
if (v.kind !== 'fact' && v.kind !== 'freeform' && v.kind !== 'futureBattleLeader' && g.futureBattleLeaderPromises?.some(row =>
|
||||
row.player === v.target && row.turn === g.turn && (row.status === 'waiting' || row.status === 'bound')))
|
||||
throw new TruthError('Finish this player’s conditional battle answer before binding another action question.');
|
||||
if (v.kind === 'charity') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,target,turn' && v.turn === g.turn &&
|
||||
g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder &&
|
||||
@@ -593,6 +597,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
return { kind: 'mixedBattlePlan', target: v.target as string, battle: g.battle.event,
|
||||
territory: g.battle.territory, mixed };
|
||||
}
|
||||
if (v.kind === 'futureBattleLeader') {
|
||||
const target = g.players.find(player => player.id === v.target)!;
|
||||
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 && g.phase === 5 && !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.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose a native leader and a quiet Basic turn before Battle.');
|
||||
return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
|
||||
}
|
||||
if (v.kind === 'battlePlan') {
|
||||
check(
|
||||
g.phase === 6 &&
|
||||
@@ -670,7 +686,8 @@ export function validateSavedTruthtrance(g: Game) {
|
||||
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
|
||||
);
|
||||
}
|
||||
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' || window?.question?.kind === 'traitorCall') {
|
||||
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' ||
|
||||
window?.question?.kind === 'traitorCall' || window?.question?.kind === 'futureBattleLeader') {
|
||||
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
|
||||
'The saved fact question must retain its asking player and answer stage.');
|
||||
check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
|
||||
@@ -794,6 +811,8 @@ export function truthQuestionText(
|
||||
return `Will you ${shipmentClaimText(q)} this turn?`;
|
||||
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?`;
|
||||
const clause = (f: TruthFact): string => {
|
||||
if (isKnowledgeFact(f)) return knowledgeFactText(f);
|
||||
if (f.kind === 'noFieldValue') return noFieldValueFactText(f);
|
||||
@@ -941,6 +960,8 @@ export function resolveTruthAction(
|
||||
mixedBattleAnswers?: (g: Game, p: Player, claim: MixedBattleClaim) => TruthAnswer[];
|
||||
bindMixedBattle?: (g: Game, p: Player, claim: MixedBattleClaim, answer: boolean, asker: string) => void;
|
||||
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
|
||||
futureBattleLeaderAnswers: (g: Game, p: Player, question: Extract<TruthQuestion, {kind: 'futureBattleLeader'}>) => TruthAnswer[];
|
||||
bindFutureBattleLeader: (g: Game, p: Player, question: Extract<TruthQuestion, {kind: 'futureBattleLeader'}>, answer: boolean, asker: string) => void;
|
||||
bindBattle: (
|
||||
g: Game,
|
||||
p: Player,
|
||||
@@ -1315,6 +1336,11 @@ export function resolveTruthAction(
|
||||
current.player,
|
||||
);
|
||||
}
|
||||
if (q.kind === 'futureBattleLeader') {
|
||||
check(effects.futureBattleLeaderAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer compatible with your available native leaders and earlier commitments.');
|
||||
if (a.answer !== 'unknown') effects.bindFutureBattleLeader(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
// Freeform semantics cannot be certified by a deterministic engine. AI does not invent an answer.
|
||||
if (q.kind === 'freeform' && p.bot)
|
||||
check(
|
||||
|
||||
Reference in new issue
Block a user