Add cumulative Truthtrance force-revival commitments
This commit is contained in:
1 parent
bd6e5bbf7f
commit
24653799cf
21 files changed
+1540
-56
No files matched your search
@@ -1307,6 +1307,7 @@ function policyActions(g: GameView): Action[] {
|
||||
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
|
||||
: [];
|
||||
}
|
||||
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
|
||||
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
|
||||
if (g.truthtrance) {
|
||||
const w = g.truthtrance;
|
||||
@@ -1314,6 +1315,10 @@ function policyActions(g: GameView): Action[] {
|
||||
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
|
||||
if (w.stage === 'answer') {
|
||||
if (w.question?.target !== me.id) return [];
|
||||
if (w.question.kind === 'revival') {
|
||||
const answer = g.truthRevivalAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'bid') {
|
||||
const answer = g.truthBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
+208
-46
@@ -18,6 +18,8 @@ import { findMovementCompletion, type MovementCompletion } from './movement-prom
|
||||
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile';
|
||||
import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile';
|
||||
import { RevivalPromiseError, revivalPromiseModeSupported, liveRevivalPromises, recordPromisedRevival,
|
||||
revivalPromisesSatisfied, validateRevivalPromises, type RevivalClaim, type RevivalPromise } from './revival-promises';
|
||||
import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, liveBidPromises,
|
||||
recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './bid-promises';
|
||||
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
|
||||
@@ -2136,6 +2138,7 @@ export type Game = {
|
||||
truthHistory?: TruthRecord[];
|
||||
shipmentPromises?: ShipmentPromise[];
|
||||
bidPromises?: BidPromise[];
|
||||
revivalPromises?: RevivalPromise[];
|
||||
movementPromises?: MovementPromise[];
|
||||
/** Server-only certainty of movement-relevant cards still in the discard. */
|
||||
movementDiscardKnowledge?: MovementDiscardKnowledge;
|
||||
@@ -18205,7 +18208,9 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
|
||||
for (const answer of [true, false]) {
|
||||
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (groundMovementCompletion(answerSource, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
|
||||
if (groundMovementCompletion(answerSource, p.id, [...live, promise]) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }))
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
@@ -26654,7 +26659,8 @@ function shipmentTruthAnswers(
|
||||
// Concurrent legacy readiness can reopen; a completed ordered turn cannot.
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
|
||||
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (findShipmentCompletion(answerSource, p, promises))
|
||||
if (findShipmentCompletion(answerSource, p, promises) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }))
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
@@ -29699,6 +29705,8 @@ function finishRevival(
|
||||
const source = revival.nexusEmperorEvent ? 'nexusEmperor' :
|
||||
revival.emperorExtra ? 'emperorExtra' : 'normal';
|
||||
const group = { amount: n, elite, free: revival.free };
|
||||
if (source === 'normal' && liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length)
|
||||
g.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(g.revivalPromises ?? [], p.id, g.turn, { amount: n, elite }));
|
||||
commitRevivalForcePieces(g, p, revival);
|
||||
finishNexusEmperorRevival(g, revival, 'returned');
|
||||
if (quote.techIncome) {
|
||||
@@ -29761,6 +29769,183 @@ function finishRevival(
|
||||
}
|
||||
}
|
||||
}
|
||||
function normalForceRevivalQuote(g: Game, p: Player, action: Action): PendingRevival {
|
||||
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
|
||||
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
|
||||
requireRule(
|
||||
elite <= eliteRevivalRemaining(p, g.advanced),
|
||||
'Only one elite force may be revived per turn.',
|
||||
);
|
||||
const availableFree = forceRevivalQuote(g, p, n).free;
|
||||
const freeElite =
|
||||
p.faction === 'ixians'
|
||||
? integer(
|
||||
action.freeElite ?? Math.min(availableFree, elite),
|
||||
Math.max(0, availableFree - (n - elite)),
|
||||
Math.min(availableFree, elite),
|
||||
'Free cyborg revivals',
|
||||
)
|
||||
: 0;
|
||||
const quote = forceRevivalQuote(g, p, n, elite, freeElite);
|
||||
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
|
||||
const ixSuboidBonus = p.faction === 'ixians' ? quoteIxSuboidBonus({
|
||||
enabled: ixHomeworldRevivalEnabled(g, p.id) && !g.revivalRules?.freeBlocked?.includes(p.id), amount: n, elite,
|
||||
free: quote.free, freeElite,
|
||||
normalTanks: p.tanks - (p.elites?.tanks ?? 0),
|
||||
}) : 0;
|
||||
const checks: PendingRevival['checks'] = [];
|
||||
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
|
||||
const uncancelableLimit = Math.max(
|
||||
g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)),
|
||||
g.revivalRules?.nexusTleilaxu?.includes(p.id) ? 5 : 0);
|
||||
if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
|
||||
checks.push('choamRevival');
|
||||
if (!choamBenefit && p.revived + n > uncancelableLimit)
|
||||
checks.push('revivalLimit');
|
||||
const factionCost = choamBenefit ? n - quote.free : quote.normalCost;
|
||||
if (quote.cost < factionCost) checks.push('revivalDiscount');
|
||||
return {
|
||||
player: p.id,
|
||||
kind: 'forces',
|
||||
amount: n,
|
||||
elite,
|
||||
...quote,
|
||||
...(ixSuboidBonus ? { ixSuboidBonus } : {}),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
function revivalPromiseRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof RevivalPromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function revivalPromiseIntegrity(g: Game) {
|
||||
revivalPromiseRule(() => validateRevivalPromises(g.revivalPromises ?? [], g.players.map(player => player.id)));
|
||||
}
|
||||
/** The current native phase only; a waiting ordered participant still owns a
|
||||
* future opportunity. Basic readiness may reopen, so No lasts to phase close. */
|
||||
function revivalOpportunity(g: Game, player: string): boolean {
|
||||
return g.status === 'playing' && g.phase === 4 && revivalPromiseModeSupported(g) &&
|
||||
!g.ready.includes(player) && (!g.revivalOrder || g.revivalOrder.remaining.includes(player));
|
||||
}
|
||||
function revivalQuestionWindow(g: Game): boolean {
|
||||
return g.status === 'playing' && g.phase === 4 && revivalPromiseModeSupported(g) &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival &&
|
||||
!g.pendingKarama && !g.pendingTreacheryDiscard;
|
||||
}
|
||||
function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival): boolean {
|
||||
if (!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
|
||||
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) return true;
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
trial.truthtrance = null;
|
||||
if (revival) {
|
||||
actor.spice -= revival.cost;
|
||||
commitRevivalForcePieces(trial, actor, revival);
|
||||
}
|
||||
// This is the final aggregate: later witnesses cannot invent another ordinary
|
||||
// revival that would bypass this question's typed No or paid-return budget.
|
||||
if (!trial.ready.includes(p.id)) trial.ready.push(p.id);
|
||||
try {
|
||||
const action = { type: 'ready' };
|
||||
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
return true;
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
/** A final aggregate can be requested together, including Ixian free-Cyborg
|
||||
* selection. The native quote owns prices, typed Tanks, free quota and limits;
|
||||
* no future gift, card draw or foreign decision is invented. */
|
||||
function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromises ?? []): Action | null {
|
||||
if (!revivalOpportunity(g, p.id)) return null;
|
||||
if (revivalPromisesSatisfied(promises, p.id, g.turn, { amount: 0, elite: 0 }) &&
|
||||
revivalKeepsLaterPromises(g, p)) return { type: 'ready' };
|
||||
if (revivalPrevented(g, p.id)) return null;
|
||||
const maximum = forceRevivalRemaining(g, p), eliteTanks = p.elites?.tanks ?? 0;
|
||||
for (let amount = 1; amount <= maximum; amount++)
|
||||
for (let elite = Math.max(0, amount - (p.tanks - eliteTanks)); elite <= Math.min(amount, eliteRevivalRemaining(p, g.advanced)); elite++) {
|
||||
if (!revivalPromisesSatisfied(promises, p.id, g.turn, { amount, elite })) continue;
|
||||
const free = forceRevivalQuote(g, p, amount).free;
|
||||
for (let freeElite = p.faction === 'ixians' ? Math.max(0, free - (amount - elite)) : 0;
|
||||
freeElite <= (p.faction === 'ixians' ? Math.min(free, elite) : 0); freeElite++) {
|
||||
const action: Action = { type: 'revive', amount, elite, ...(p.faction === 'ixians' ? { freeElite } : {}) };
|
||||
try {
|
||||
const revival = normalForceRevivalQuote(g, p, action);
|
||||
if (revivalKeepsLaterPromises(g, p, revival)) return action;
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises'>): boolean {
|
||||
if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length) return true;
|
||||
const trial = { ...g, ...later };
|
||||
return !!revivalPromiseCompletion(trial, getPlayer(trial, p.id));
|
||||
}
|
||||
function revivalTruthAnswers(g: Game, p: Player, claim: RevivalClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
if (!revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || claim.turn !== g.turn) return [];
|
||||
return (['yes', 'no'] as const).filter(answer => !!revivalPromiseCompletion(g, p, [...(g.revivalPromises ?? []),
|
||||
{ ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host,
|
||||
answer: answer === 'yes', progress: 0 }]));
|
||||
}
|
||||
function bindRevivalTruth(g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) {
|
||||
requireRule(revivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'This revival answer no longer has a legal completion.');
|
||||
(g.revivalPromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 });
|
||||
}
|
||||
function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action: Action }) {
|
||||
if (!g.revivalPromises?.some(promise => !promise.fulfilled && !promise.released)) return;
|
||||
revivalPromiseIntegrity(g);
|
||||
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingRevival ||
|
||||
g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
|
||||
const kept: RevivalPromise[] = [];
|
||||
const no = g.revivalPromises.filter(promise => !promise.answer && !promise.fulfilled && !promise.released);
|
||||
for (const promise of g.revivalPromises) {
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
const same = g.status === 'playing' && g.phase === 4 && g.turn === promise.turn;
|
||||
if (!promise.answer) {
|
||||
if (!same || g.revivalOrder && !g.revivalOrder.remaining.includes(promise.player)) promise.fulfilled = true;
|
||||
continue;
|
||||
}
|
||||
if (!same || !revivalPromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise])) {
|
||||
requireRule(voluntary?.actor !== promise.player,
|
||||
'You cannot voluntarily evade your Truthtrance revival answer.');
|
||||
promise.released = true;
|
||||
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance revival answer; that answer is no longer binding.`);
|
||||
} else kept.push(promise);
|
||||
}
|
||||
}
|
||||
function projectedRevivalPromiseGuide(g: Game, player: string): { action: Action } | null {
|
||||
if (!revivalQuestionWindow(g) || g.truthtrance || !revivalOpportunity(g, player) ||
|
||||
nativeRevivalOrderBlock(g, player)) return null;
|
||||
const promise = liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0];
|
||||
if (!promise) return null;
|
||||
const action = revivalPromiseCompletion(g, getPlayer(g, player));
|
||||
return action ? { action: { ...action, revivalPromiseEvent: promise.event } } : null;
|
||||
}
|
||||
function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: PendingRevival) {
|
||||
const live = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn);
|
||||
if (action.revivalPromiseEvent !== undefined) requireRule(revivalOpportunity(g, p.id) &&
|
||||
typeof action.revivalPromiseEvent === 'string' && live.some(promise => promise.event === action.revivalPromiseEvent),
|
||||
'This revival commitment guide is stale.');
|
||||
if (!live.length) return;
|
||||
if (!revival) {
|
||||
requireRule(revivalPromisesSatisfied(g.revivalPromises ?? [], p.id, g.turn, { amount: 0, elite: 0 }),
|
||||
'Complete the force returns required by your Truthtrance Yes before finishing Revival.');
|
||||
return;
|
||||
}
|
||||
// Preflight a voluntary request against its actual current invoice. Only the
|
||||
// completed real return below records progress; an opposing stop records none.
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
trial.truthtrance = null;
|
||||
trial.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(trial.revivalPromises ?? [], p.id,
|
||||
g.turn, { amount: revival.amount!, elite: revival.elite ?? 0 }));
|
||||
// Legacy concurrent Ready does not forbid a later native request. Its No
|
||||
// stays binding to phase close; only the detached completion probe reopens.
|
||||
if (!trial.revivalOrder) trial.ready = trial.ready.filter(player => player !== p.id);
|
||||
actor.spice -= revival.cost;
|
||||
commitRevivalForcePieces(trial, actor, revival);
|
||||
requireRule(!!revivalPromiseCompletion(trial, actor),
|
||||
'This request would prevent you from completing your Truthtrance revival answer.');
|
||||
}
|
||||
function beginRevival(g: Game, revival: PendingRevival) {
|
||||
requireRule(
|
||||
!revivalPrevented(g, revival.player),
|
||||
@@ -34568,6 +34753,7 @@ function normalizeCardNames(g: Game) {
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
@@ -34671,8 +34857,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
);
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
if (action.revivalPromiseEvent !== undefined) requireRule(action.type === 'revive' || action.type === 'ready',
|
||||
'This revival commitment guide cannot authorize another action.');
|
||||
const g = applyActionInner(state, id, action);
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileGroundMovementPromises(g, id, action, state.phase);
|
||||
reconcileTraitorTruth(g, id, action);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
@@ -34795,6 +34984,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileBattlePromises(g, { actor: id, action });
|
||||
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
reconcileHomeworldEcazDuke(g);
|
||||
@@ -34996,6 +35186,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
requireRule(
|
||||
@@ -35010,6 +35201,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
@@ -35094,6 +35286,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
settleAutomaticContinuations(g);
|
||||
@@ -35339,6 +35532,8 @@ function applyActionInner(
|
||||
bindShipment: bindShipmentTruth,
|
||||
movementAnswers: movementTruthAnswers,
|
||||
bindMovement: bindMovementTruth,
|
||||
revivalAnswers: revivalTruthAnswers,
|
||||
bindRevival: bindRevivalTruth,
|
||||
bidAnswers: bidTruthAnswers,
|
||||
bindBid: bindBidTruth,
|
||||
traitorAnswers: traitorTruthAnswers,
|
||||
@@ -35350,7 +35545,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
if (error instanceof TruthError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -38104,49 +38299,9 @@ function applyActionInner(
|
||||
if (t === 'revive') {
|
||||
requireRule(g.phase === 4, 'Revival is not the current phase.');
|
||||
if (execution === 'live') startNativeRevivalOrder(g,id);
|
||||
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
|
||||
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
|
||||
requireRule(
|
||||
elite <= eliteRevivalRemaining(p, g.advanced),
|
||||
'Only one elite force may be revived per turn.',
|
||||
);
|
||||
const availableFree = forceRevivalQuote(g, p, n).free;
|
||||
const freeElite =
|
||||
p.faction === 'ixians'
|
||||
? integer(
|
||||
action.freeElite ?? Math.min(availableFree, elite),
|
||||
Math.max(0, availableFree - (n - elite)),
|
||||
Math.min(availableFree, elite),
|
||||
'Free cyborg revivals',
|
||||
)
|
||||
: 0;
|
||||
const quote = forceRevivalQuote(g, p, n, elite, freeElite);
|
||||
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
|
||||
const ixSuboidBonus = p.faction === 'ixians' ? quoteIxSuboidBonus({
|
||||
enabled: ixHomeworldRevivalEnabled(g, id) && !g.revivalRules?.freeBlocked?.includes(id), amount: n, elite,
|
||||
free: quote.free, freeElite,
|
||||
normalTanks: p.tanks - (p.elites?.tanks ?? 0),
|
||||
}) : 0;
|
||||
const checks: PendingRevival['checks'] = [];
|
||||
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
|
||||
const uncancelableLimit = Math.max(
|
||||
g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)),
|
||||
g.revivalRules?.nexusTleilaxu?.includes(p.id) ? 5 : 0);
|
||||
if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
|
||||
checks.push('choamRevival');
|
||||
if (!choamBenefit && p.revived + n > uncancelableLimit)
|
||||
checks.push('revivalLimit');
|
||||
const factionCost = choamBenefit ? n - quote.free : quote.normalCost;
|
||||
if (quote.cost < factionCost) checks.push('revivalDiscount');
|
||||
beginRevival(g, {
|
||||
player: id,
|
||||
kind: 'forces',
|
||||
amount: n,
|
||||
elite,
|
||||
...quote,
|
||||
...(ixSuboidBonus ? { ixSuboidBonus } : {}),
|
||||
checks,
|
||||
});
|
||||
const revival = normalForceRevivalQuote(g, p, action);
|
||||
if (execution === 'live') guardPromisedRevival(g, p, action, revival);
|
||||
beginRevival(g, revival);
|
||||
return g;
|
||||
}
|
||||
if (t === 'reviveKwisatz' || t === 'reviveLeader') {
|
||||
@@ -39109,6 +39264,8 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'ready') {
|
||||
if (g.phase === 4) guardPromisedRevival(g, p, action);
|
||||
else requireRule(action.revivalPromiseEvent === undefined, 'This revival commitment guide is stale.');
|
||||
requireRule(
|
||||
[1, 2, 4, 7, 8].includes(g.phase) ||
|
||||
(g.phase === 0 && g.stormPending !== null),
|
||||
@@ -39302,6 +39459,11 @@ export function viewGame(state: Game, id: string) {
|
||||
}).map(player => player.id) : [],
|
||||
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
truthRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
|
||||
truthRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
revivalPromises: (g.revivalPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
|
||||
revivalPromiseGuide: projectedRevivalPromiseGuide(g, id),
|
||||
bidPromises: projectedBidPromises(g, id),
|
||||
bidPromiseGuide: projectedBidPromiseGuide(g, id),
|
||||
truthtrance: g.truthtrance ?? null,
|
||||
|
||||
+29
-2
@@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-revival',
|
||||
title: 'Truthtrance: ordinary force-revival promises',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind cumulative future total, normal or elite physical returns in the current native Revival phase.',
|
||||
steps: [
|
||||
'Choose Bind ordinary force revivals this phase, another participant with a remaining native opportunity, total/normal/elite counters and a minimum from1 to20. This first version supports registered native Basic and Advanced tables without optional modules; it opens no new game-start configuration.',
|
||||
'Only ordinary force returns actually completed after your answer count. Each elite is one physical counter. Earlier returns, leaders, Ghola, Emperor-funded extras and other special returns are separate. Smaller ordinary requests add together.',
|
||||
'Yes requires enough matching returns while possible. No forbids reaching the minimum, even through several smaller requests. Several answers must remain jointly possible. The original typed Tanks, free allowance, paid prices, elite limits and Revival order remain authoritative.',
|
||||
'The private answer choices and guide use current native quotas and funding, including native free/paid selection. Earlier shipment and ground-movement promises retain their funding. Unknown gifts, future draws and foreign choices are not assumed.',
|
||||
'Your own request, spending or Finish Revival cannot evade an unmet Yes. A completed opposing effect may release an impossible answer in accepted order. A requested return does not fulfill Yes until the actual return completes; its original responses still decide the outcome.',
|
||||
'The persistent own promise panel displays progress and fulfilled/released status. It waits for your actual native opportunity before offering a normal revival or Finish Revival. Basic No remains through phase close because native readiness can reopen; stamped Advanced No ends when your opportunity finishes.',
|
||||
'This cumulative current-phase protocol is a provisional online composition of general current-turn Truthtrance authority. Earlier-phase questions, optional-module compositions, special/card revival claims, grouped revival expressions and complete-mode acceptance remain required work.',
|
||||
],
|
||||
related: ['card-truthtrance', 'advanced-revival-order', 'truthtrance-shipment', 'truthtrance-movement', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Typed cumulative current-phase native ordinary returns, real completion, compatible answers, voluntary guards and forced release retain original cost/quota/order.', evidence: ['game/revival-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/revival-promises.test.ts', 'tests/truth-revival-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Structured question, target-only feasible answers and persistent event-bound normal revival/finish guide.', evidence: ['components/revival-claim-fields.tsx', 'components/revival-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-revival-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing four profiles use feasible answers and the native action guide; strategy and difficulty calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-revival-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'General authority, provisional timing/count interpretation, native order and remaining boundaries are explicit.', evidence: ['docs/TRUTHTRANCE_REVIVAL_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused native original-deal/death, pure and control evidence; broader combinations, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-revival.ts', 'tests/truth-revival-runtime.test.ts', 'tests/truth-revival-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-bid',
|
||||
title: 'Truthtrance: a bid in the current ordinary lot',
|
||||
@@ -3222,7 +3247,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Available answers use current native funding, prepaid allied aid and a spendable Karama. Earlier shipment and movement obligations retain their funding. Unknown future purchases or rival income are not assumed. Several answers must remain jointly possible: Yes sets the required minimum and No can cap a later bid.',
|
||||
'Your own voluntary spending cannot evade Yes. A completed opposing raise may make it impossible and release that answer; still-possible answers and No constraints remain. Closed lots finish No and cannot bind the next lot or turn.',
|
||||
'Your persistent bid-promise panel waits for your actual active seat, then offers a legal bid or pass. It shows fulfilled and released status without timing out. Only the questioned player receives its feasible answers and private guide; the question and reply are public.',
|
||||
'This current-lot and next-opportunity protocol is a provisional online composition of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped/phase-wide bidding commitments, force-revival commitments and complete-mode acceptance remain unfinished.',
|
||||
'This current-lot and next-opportunity protocol is a provisional online composition of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped/phase-wide bidding commitments, broader force-revival commitments and complete-mode acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'bidding', 'truthtrance-shipment', 'implementation-checklist'],
|
||||
checklist: [
|
||||
@@ -3382,7 +3407,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
steps: [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
@@ -3394,6 +3419,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
|
||||
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
|
||||
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
|
||||
'An ordinary force-revival question binds cumulative future total, normal or elite returns in the current native Revival phase. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.',
|
||||
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
|
||||
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
|
||||
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
|
||||
@@ -3411,6 +3437,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-shipment',
|
||||
'truthtrance-movement',
|
||||
'truthtrance-bid',
|
||||
'truthtrance-revival',
|
||||
'richese-no-field',
|
||||
'card-reinforcements',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
|
||||
export type RevivalClaim = { turn: number; counter: 'total' | 'normal' | 'elite'; minimum: number };
|
||||
export type RevivalPromise = RevivalClaim & {
|
||||
event: string;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
/** Physical ordinary force returns after this answer, excluding prior returns. */
|
||||
progress: number;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
type RevivalGroup = { amount: number; elite: number };
|
||||
|
||||
export class RevivalPromiseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RevivalPromiseError';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRevivalClaim(value: unknown): RevivalClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new RevivalPromiseError('Choose a current-turn ordinary force-revival question.');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (typeof input.turn !== 'number' || !Number.isSafeInteger(input.turn) || input.turn < 1 ||
|
||||
(input.counter !== 'total' && input.counter !== 'normal' && input.counter !== 'elite') ||
|
||||
typeof input.minimum !== 'number' || !Number.isSafeInteger(input.minimum) ||
|
||||
input.minimum < 1 || input.minimum > 20)
|
||||
throw new RevivalPromiseError('Choose the current turn, physical counter type and a whole revival minimum from 1 to 20.');
|
||||
return { turn: input.turn, counter: input.counter, minimum: input.minimum };
|
||||
}
|
||||
|
||||
/** The original bare native scope is unchanged. Timing, allowance, payment,
|
||||
* typed Tanks and actual ordinary-return sources remain the engine's rules. */
|
||||
export function revivalPromiseModeSupported(game: NativePromiseScope & {
|
||||
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
|
||||
richeseBetrayalPreview?: unknown;
|
||||
}): boolean {
|
||||
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
|
||||
!game.nexusKullPreview && !game.richeseBetrayalPreview;
|
||||
}
|
||||
|
||||
export function revivalClaimText(claim: RevivalClaim): string {
|
||||
const counters = claim.counter === 'total' ? 'physical forces' :
|
||||
claim.counter === 'normal' ? 'normal forces' : 'elite forces';
|
||||
return `revive at least ${claim.minimum} ${counters} through ordinary force revival from now until your Revival opportunity ends this turn`;
|
||||
}
|
||||
|
||||
export function liveRevivalPromises(promises: readonly RevivalPromise[], player: string, turn: number): RevivalPromise[] {
|
||||
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
}
|
||||
|
||||
function validateGroup(group: RevivalGroup): void {
|
||||
if (!group || typeof group !== 'object' || Array.isArray(group) ||
|
||||
!Number.isSafeInteger(group.amount) || group.amount < 0 || group.amount > 20 ||
|
||||
!Number.isSafeInteger(group.elite) || group.elite < 0 || group.elite > group.amount)
|
||||
throw new RevivalPromiseError('Retain a physical revival group of up to 20 forces with its actual elite count.');
|
||||
}
|
||||
|
||||
/** Each elite is one physical counter. Its battle strength is irrelevant. */
|
||||
export function revivalPromiseCount(claim: RevivalClaim, group: RevivalGroup): number {
|
||||
const parsed = parseRevivalClaim(claim);
|
||||
validateGroup(group);
|
||||
return parsed.counter === 'total' ? group.amount : parsed.counter === 'normal' ? group.amount - group.elite : group.elite;
|
||||
}
|
||||
|
||||
/** Provisional cumulative composition: only the caller's actual ordinary force
|
||||
* return commits progress. Special revivals, leaders and previous returns do
|
||||
* not call this function. A No cap applies across split requests as well. */
|
||||
export function recordPromisedRevival(promises: readonly RevivalPromise[], player: string, turn: number,
|
||||
group: RevivalGroup): RevivalPromise[] {
|
||||
validateGroup(group);
|
||||
const live = liveRevivalPromises(promises, player, turn);
|
||||
const progress = new Map<RevivalPromise, number>();
|
||||
for (const promise of live) {
|
||||
const next = promise.progress + revivalPromiseCount(promise, group);
|
||||
if (!Number.isSafeInteger(next) || next > 20)
|
||||
throw new RevivalPromiseError('The revival commitment exceeds its physical progress limit.');
|
||||
if (!promise.answer && next >= promise.minimum)
|
||||
throw new RevivalPromiseError('This ordinary revival conflicts with your cumulative Truthtrance No answer.');
|
||||
progress.set(promise, next);
|
||||
}
|
||||
return promises.map(promise => {
|
||||
const next = progress.get(promise);
|
||||
return next === undefined || next === promise.progress ? promise : {
|
||||
...promise, progress: next,
|
||||
...(promise.answer && next >= promise.minimum ? { fulfilled: true } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Test final additional ordinary returns, including none, against every live
|
||||
* answer together. This does not grant the quoted returns or mutate progress. */
|
||||
export function revivalPromisesSatisfied(promises: readonly RevivalPromise[], player: string, turn: number,
|
||||
group: RevivalGroup): boolean {
|
||||
validateGroup(group);
|
||||
return liveRevivalPromises(promises, player, turn).every(promise => {
|
||||
const next = promise.progress + revivalPromiseCount(promise, group);
|
||||
return Number.isSafeInteger(next) && next <= 20 && (next >= promise.minimum) === promise.answer;
|
||||
});
|
||||
}
|
||||
|
||||
const identifier = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
||||
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
|
||||
|
||||
export function validateRevivalPromises(promises: readonly RevivalPromise[], players: readonly string[]): void {
|
||||
if (!Array.isArray(promises)) throw new RevivalPromiseError('The revival commitment record is invalid.');
|
||||
const events = new Set<string>();
|
||||
for (const promise of promises) {
|
||||
parseRevivalClaim(promise);
|
||||
if (!identifier(promise.event) || events.has(promise.event) ||
|
||||
!identifier(promise.player) || !identifier(promise.asker) ||
|
||||
!players.includes(promise.player) || !players.includes(promise.asker) || promise.player === promise.asker ||
|
||||
typeof promise.answer !== 'boolean' || !Number.isSafeInteger(promise.progress) ||
|
||||
promise.progress < 0 || promise.progress > 20 ||
|
||||
!optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) ||
|
||||
(promise.fulfilled && promise.released) ||
|
||||
(!promise.answer && promise.progress >= promise.minimum) ||
|
||||
(promise.answer && !promise.released && !!promise.fulfilled !== (promise.progress >= promise.minimum)))
|
||||
throw new RevivalPromiseError('The revival commitment lost its event, participants or cumulative answer progress.');
|
||||
events.add(promise.event);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises';
|
||||
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
import type { ShipmentClaim } from './shipment-promises';
|
||||
@@ -60,6 +61,7 @@ export type TruthFact =
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
||||
| ({ kind: 'bid'; target: string } & BidClaim)
|
||||
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
||||
| ({ kind: 'movement'; target: string } & MovementClaim)
|
||||
@@ -212,6 +214,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.players.some((p) => p.id === v.target),
|
||||
'Ask one other player.',
|
||||
);
|
||||
if (v.kind === 'revival') {
|
||||
check(Object.keys(v).sort().join(',') === 'counter,kind,minimum,target,turn',
|
||||
'Supply the current Revival turn, physical counter type, minimum and respondent.');
|
||||
const claim = parseRevivalClaim(v);
|
||||
check(revivalPromiseModeSupported(g) && g.status === 'playing' && g.phase === 4 &&
|
||||
claim.turn === g.turn && !g.ready.includes(v.target as string) &&
|
||||
(!g.revivalOrder || g.revivalOrder.remaining.includes(v.target as string)) &&
|
||||
!g.pendingRevival && !g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose a remaining native Revival opportunity in a supported table and finish pending decisions.');
|
||||
return { kind: 'revival', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'bid') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,lot,minimum,target,turn',
|
||||
'Supply the current ordinary lot, turn, bid minimum and respondent.');
|
||||
@@ -447,6 +461,7 @@ export function truthQuestionText(
|
||||
locationName: (id: string) => string,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
|
||||
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
|
||||
@@ -493,6 +508,8 @@ export function resolveTruthAction(
|
||||
) => void;
|
||||
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
|
||||
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
|
||||
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
|
||||
bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void;
|
||||
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
|
||||
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
|
||||
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
|
||||
@@ -613,6 +630,9 @@ export function resolveTruthAction(
|
||||
'Waiting for the Truthtrance holder to ask a question.',
|
||||
);
|
||||
w.question = parseQuestion(g, p.id, a.question);
|
||||
if (w.question.kind === 'revival')
|
||||
check(effects.revivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a respondent with a remaining native Revival opportunity.');
|
||||
if (w.question.kind === 'bid')
|
||||
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a bidder with a remaining ordinary opportunity in this lot.');
|
||||
@@ -666,6 +686,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer consistent with legal ground movement and earlier promises.');
|
||||
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'revival') {
|
||||
check(effects.revivalAnswers && effects.bindRevival && effects.revivalAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with native force revivals and earlier answers.');
|
||||
if (a.answer !== 'unknown') effects.bindRevival(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'bid') {
|
||||
check(effects.bidAnswers && effects.bindBid && effects.bidAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
|
||||
|
||||
Reference in new issue
Block a user