Bind current Traitor declarations with Truthtrance
This commit is contained in:
1 parent
724eabf99c
commit
86608f7ea9
20 files changed
+1362
-35
No files matched your search
+8
-1
@@ -1292,6 +1292,11 @@ 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 === 'traitorCall') {
|
||||
const answers = g.truthTraitorAnswers ?? [];
|
||||
const answer = answers.find(answer => answer === 'yes') ?? answers[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'movement') {
|
||||
const answer = g.truthMovementAnswers?.find(answer => answer !== 'unknown') ?? g.truthMovementAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
@@ -3904,7 +3909,7 @@ function policyActions(g: GameView): Action[] {
|
||||
? me.id
|
||||
: me.ally;
|
||||
const enemy = b.attacker === beneficiary ? b.defender : b.attacker;
|
||||
const call =
|
||||
const preferredCall =
|
||||
ownsTraitorDecision &&
|
||||
!b.plans[enemy].kwisatz &&
|
||||
!!matchingTraitor(
|
||||
@@ -3912,6 +3917,8 @@ function policyActions(g: GameView): Action[] {
|
||||
b.plans[enemy].leader,
|
||||
b.cards.find((c) => c.id === b.plans[enemy].leader),
|
||||
);
|
||||
const choices = b.traitorCallChoices;
|
||||
const call = choices && !choices.includes(preferredCall) ? choices[0] ?? false : preferredCall;
|
||||
const portable = g.portableSnooper;
|
||||
if (
|
||||
!call &&
|
||||
|
||||
+123
-23
@@ -1,4 +1,7 @@
|
||||
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { traitorDecisionChoices, bindTraitorDecisionPromise, settleTraitorDecisionPromises,
|
||||
reconcileTraitorDecisionPromises as reconcileTraitorDecisions, validateTraitorDecisionPromise,
|
||||
TraitorDecisionError, type TraitorDecisionContext, type TraitorDecisionPromise } from './traitor-decision-promises';
|
||||
import { gholaForcePreparations } from './ghola-force-preparation';
|
||||
import { earlyAuctionFunding } from './early-auction-funding';
|
||||
import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge';
|
||||
@@ -838,6 +841,7 @@ export type Battle = {
|
||||
plans: Record<string, Plan>;
|
||||
revealed: boolean;
|
||||
traitorCalls: Record<string, boolean>;
|
||||
traitorDecisionPromises?: TraitorDecisionPromise[];
|
||||
/** Original native allowance, retained separately from the Nexus source. */
|
||||
nexusHarkonnenAllowance?: {
|
||||
provider: string;
|
||||
@@ -1669,6 +1673,7 @@ export type Game = {
|
||||
| { kind: 'shipment'; index: number; value: ShipmentPromise }
|
||||
| { kind: 'movement'; index: number; value: MovementPromise }
|
||||
| { kind: 'battle'; index: number; value: BattlePromise }
|
||||
| { kind: 'traitor'; index: number; value: TraitorDecisionPromise }
|
||||
| null;
|
||||
parentSignature: string;
|
||||
}
|
||||
@@ -3524,7 +3529,8 @@ function nexusTraitorParentSignature(g: Game, event: string) {
|
||||
const resume = { response: context.response ?? null, decision: context.decision ?? null,
|
||||
pendingKarama: 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] ?? null : null,
|
||||
phaseOpening: 'phaseOpening' in context ? context.phaseOpening as Game['phaseOpening'] ?? null : null };
|
||||
const battle = g.battle ? Object.fromEntries(Object.entries(g.battle).filter(([key]) => key !== 'truthPromises')) : null;
|
||||
const battle = g.battle ? Object.fromEntries(Object.entries(g.battle).filter(([key]) =>
|
||||
key !== 'truthPromises' && key !== 'traitorDecisionPromises')) : null;
|
||||
return JSON.stringify({ event, turn: g.turn, phase: g.phase, status: g.status, active: g.active,
|
||||
order: g.order, ready: g.ready, battle, nullentropy: g.pendingNullentropy ?? null,
|
||||
controls: nullentropyParentSignature(g, resume, event) });
|
||||
@@ -3629,6 +3635,8 @@ function finishNexusTraitorReturn(g: Game, p: Player, action: Action) {
|
||||
requireRule(pending?.owner === p.id && action.event === pending.event &&
|
||||
Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards),
|
||||
'This Nexus return is stale or belongs to another player.');
|
||||
const promiseBlock = traitorReturnPromiseBlock(g, p, action.cards as string[]);
|
||||
requireRule(!promiseBlock, promiseBlock ?? 'Preserve your accepted Traitor declaration.');
|
||||
const result = nexusRule(() => finishNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g),
|
||||
pending, action.cards as string[], random));
|
||||
applyNexusTraitorSnapshot(g, result.state);
|
||||
@@ -3641,11 +3649,16 @@ function projectedNexusTraitors(g: Game, id: string) {
|
||||
if (!g.nexusCards?.cards) return null;
|
||||
const pending = pendingNexusTraitors(g);
|
||||
const owner = pending ? getPlayer(g, pending.owner) : null;
|
||||
const choices: {id:string;revealed:boolean;drawn:boolean;blocked?:string}[] = !pending || pending.owner !== id ? []
|
||||
: owner!.faction === 'tleilaxu'
|
||||
? owner!.faceDancers!.filter(card => !card.revealed).map(card => ({id:card.leader, revealed:card.revealed, drawn:pending.drawn.includes(card.leader)}))
|
||||
: owner!.traitors.map(card => {
|
||||
const blocked = traitorReturnPromiseBlock(g, owner!, [card]);
|
||||
return {id:card, revealed:false, drawn:pending.drawn.includes(card), ...(blocked ? {blocked} : {})};
|
||||
});
|
||||
return { offer: nexusTraitorOffer(g, id), pending: pending ? {
|
||||
event: pending.event, owner: pending.owner, mode: pending.mode, count: pending.drawn.length,
|
||||
choices: pending.owner !== id ? [] : (owner!.faction === 'tleilaxu'
|
||||
? owner!.faceDancers!.filter(card => !card.revealed).map(card => ({id:card.leader, revealed:card.revealed, drawn:pending.drawn.includes(card.leader)}))
|
||||
: owner!.traitors.map(card => ({id:card, revealed:false, drawn:pending.drawn.includes(card)}))),
|
||||
choices,
|
||||
} : null };
|
||||
}
|
||||
|
||||
@@ -6179,6 +6192,9 @@ function finishTruthtranceAnswer(g: Game) {
|
||||
if (record.question.kind === 'movement') promise = {
|
||||
kind: 'movement', index: g.movementPromises!.length - 1, value: g.movementPromises!.at(-1)!,
|
||||
};
|
||||
else if (record.question.kind === 'traitorCall') promise = {
|
||||
kind: 'traitor', index: g.battle!.traitorDecisionPromises!.length - 1, value: g.battle!.traitorDecisionPromises!.at(-1)!,
|
||||
};
|
||||
else if (record.question.kind === 'shipment' || record.question.kind === 'mixedShipment')
|
||||
promise = {
|
||||
kind: 'shipment',
|
||||
@@ -7066,15 +7082,16 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
!g.battle.revealed &&
|
||||
!g.battle.plans[q.target];
|
||||
const expectedMovement = q.kind === 'movement';
|
||||
if (expectedShipment || expectedBattle || expectedMovement) {
|
||||
const actual = expectedMovement ? g.movementPromises : expectedShipment
|
||||
const expectedTraitor = q.kind === 'traitorCall';
|
||||
if (expectedShipment || expectedBattle || expectedMovement || expectedTraitor) {
|
||||
const actual = expectedTraitor ? g.battle?.traitorDecisionPromises : expectedMovement ? g.movementPromises : expectedShipment
|
||||
? g.shipmentPromises
|
||||
: g.battle?.truthPromises;
|
||||
requireRule(
|
||||
promise &&
|
||||
promise.value &&
|
||||
typeof promise.value === 'object' &&
|
||||
promise.kind === (expectedMovement ? 'movement' : expectedShipment ? 'shipment' : 'battle') &&
|
||||
promise.kind === (expectedTraitor ? 'traitor' : expectedMovement ? 'movement' : expectedShipment ? 'shipment' : 'battle') &&
|
||||
Array.isArray(actual) &&
|
||||
Number.isSafeInteger(promise.index) &&
|
||||
promise.index >= 0 &&
|
||||
@@ -7087,7 +7104,11 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
!promise.value.released,
|
||||
'The completed Truthtrance is missing its newly bound promise.',
|
||||
);
|
||||
if (promise.kind === 'movement')
|
||||
if (promise.kind === 'traitor')
|
||||
requireRule(q.kind === 'traitorCall' && promise.value.battle === q.battle &&
|
||||
promise.value.turn === g.turn && !promise.value.fulfilled,
|
||||
'The completed Truthtrance Traitor promise differs from its original decision.');
|
||||
else if (promise.kind === 'movement')
|
||||
requireRule(q.kind === 'movement' && promise.value.turn === g.turn && !promise.value.fulfilled &&
|
||||
promise.value.territory === q.territory && promise.value.minimum === q.minimum,
|
||||
'The completed Truthtrance ground-movement promise differs from its answer.');
|
||||
@@ -24740,6 +24761,7 @@ function findReachableBattlePlan(
|
||||
function shipmentPromiseIntegrity(g: Game) {
|
||||
try {
|
||||
validateSavedTruthtrance(g);
|
||||
traitorTruthIntegrity(g);
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
@@ -25797,6 +25819,82 @@ function traitorBeneficiary(g: Game, b: Battle, id: string) {
|
||||
if (b.ecazOccupy?.lead && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id)) return b.ecazOccupy.lead;
|
||||
return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally;
|
||||
}
|
||||
/** One native eligibility quote for declarations and private Truthtrance answers. */
|
||||
function traitorCallBlock(g: Game, b: Battle, id: string, held = getPlayer(g, id).traitors): string | null {
|
||||
const other = b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker;
|
||||
if (b.plans[other].kwisatz) return 'Kwisatz Haderach prevents this leader from turning traitor.';
|
||||
return matchingTraitor(held, b.plans[other].leader, cardOf(getPlayer(g, other), b.plans[other].leader))
|
||||
? null : 'You do not hold that leader as a traitor.';
|
||||
}
|
||||
function traitorTruthContext(g: Game, player: string, held?: string[]): TraitorDecisionContext | null {
|
||||
const b = g.battle;
|
||||
if (g.status !== 'playing' || g.phase !== 6 || !b?.revealed || !b.event || !traitorVoters(g, b).includes(player)) return null;
|
||||
const beneficiary = traitorBeneficiary(g, b, player);
|
||||
if (!beneficiary) return null;
|
||||
const target = b.attacker === beneficiary ? b.defender : b.attacker;
|
||||
if (!b.plans[target]) return null;
|
||||
return { turn: g.turn, battle: b.event, player, beneficiary, target, leader: b.plans[target].leader,
|
||||
canReveal: !traitorCallBlock(g, b, player, held),
|
||||
...(b.traitorCalls[player] === undefined ? {} : { submitted: b.traitorCalls[player] }) };
|
||||
}
|
||||
function traitorDecisionRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
catch (error) { if (error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function traitorTruthAnswers(g: Game, p: Player): TruthAnswer[] {
|
||||
const context = traitorTruthContext(g, p.id);
|
||||
return context ? traitorDecisionRule(() => traitorDecisionChoices(context, g.battle?.traitorDecisionPromises ?? []))
|
||||
.map(call => call ? 'yes' as const : 'no' as const) : [];
|
||||
}
|
||||
function bindTraitorTruth(g: Game, p: Player, answer: boolean, asker: string) {
|
||||
const context = traitorTruthContext(g, p.id);
|
||||
requireRule(context, 'Retain the current unsubmitted Traitor decision.');
|
||||
g.battle!.traitorDecisionPromises = traitorDecisionRule(() =>
|
||||
bindTraitorDecisionPromise(context, g.battle!.traitorDecisionPromises ?? [], asker, answer));
|
||||
}
|
||||
function traitorTruthIntegrity(g: Game) {
|
||||
const b = g.battle, promises = b?.traitorDecisionPromises;
|
||||
if (promises !== undefined) {
|
||||
requireRule(Array.isArray(promises) && promises.every(validateTraitorDecisionPromise), 'Invalid Traitor decision promises.');
|
||||
for (const promise of promises) {
|
||||
const context = traitorTruthContext(g, promise.player);
|
||||
requireRule(context && promise.turn === context.turn && promise.battle === context.battle &&
|
||||
promise.beneficiary === context.beneficiary && promise.target === context.target && promise.leader === context.leader &&
|
||||
g.players.some(p => p.id === promise.asker && p.id !== promise.player) &&
|
||||
!(promise.fulfilled && promise.released) &&
|
||||
(promise.fulfilled || promise.released || context.submitted === undefined),
|
||||
'The Traitor promise lost its original public battle/voter source.');
|
||||
}
|
||||
}
|
||||
const question = g.truthtrance?.question;
|
||||
if (question?.kind === 'traitorCall') {
|
||||
const context = traitorTruthContext(g, question.target);
|
||||
requireRule(context && context.submitted === undefined && context.battle === question.battle,
|
||||
'This Truthtrance requires the original unsubmitted native Traitor voter.');
|
||||
}
|
||||
}
|
||||
function reconcileTraitorTruth(g: Game, actor?: string, action?: Action) {
|
||||
const b = g.battle;
|
||||
if (!b?.traitorDecisionPromises?.length) return;
|
||||
for (const player of new Set(b.traitorDecisionPromises.map(promise => promise.player))) {
|
||||
const context = traitorTruthContext(g, player);
|
||||
if (!context || context.submitted !== undefined) continue;
|
||||
const old: TraitorDecisionPromise[] = b.traitorDecisionPromises;
|
||||
const next: TraitorDecisionPromise[] = traitorDecisionRule(() => reconcileTraitorDecisions(context, old));
|
||||
if (next.some((promise, index) => promise.released && !old[index].released)) {
|
||||
requireRule(actor !== player || action?.type === 'passResponse',
|
||||
'Your action would remove the legal Traitor declaration required by Truthtrance.');
|
||||
log(g, `${getPlayer(g, player).name}'s Truthtrance Traitor declaration promise is released because its original Reveal is no longer possible.`);
|
||||
}
|
||||
b.traitorDecisionPromises = next;
|
||||
}
|
||||
}
|
||||
function traitorReturnPromiseBlock(g: Game, p: Player, returned: readonly string[]): string | null {
|
||||
const promises = g.battle?.traitorDecisionPromises;
|
||||
if (!promises?.some(promise => promise.player === p.id && promise.answer && !promise.fulfilled && !promise.released)) return null;
|
||||
const context = traitorTruthContext(g, p.id, p.traitors.filter(card => !returned.includes(card)));
|
||||
return context && !context.canReveal ? 'Keep the identity needed by your accepted Traitor declaration; return another card.' : null;
|
||||
}
|
||||
function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null {
|
||||
const b = g.battle!;
|
||||
const frame = JSON.stringify({ plans: b.plans, hidden: b.leaderSkillHidden,
|
||||
@@ -32965,6 +33063,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
reconcileGroundMovementPromises(g, id, action, state.phase);
|
||||
reconcileTraitorTruth(g, id, action);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
if (g.pendingNexusHarkonnenBetrayal) return g;
|
||||
ixianNexusBetrayalIntegrity(g);
|
||||
@@ -33268,6 +33367,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
requireRule(
|
||||
JSON.stringify(g) !== before,
|
||||
'Automatic response continuation made no progress.',
|
||||
@@ -33362,6 +33462,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
discoveryIntegrity(g);
|
||||
@@ -33596,6 +33697,8 @@ function applyActionInner(
|
||||
bindShipment: bindShipmentTruth,
|
||||
movementAnswers: movementTruthAnswers,
|
||||
bindMovement: bindMovementTruth,
|
||||
traitorAnswers: traitorTruthAnswers,
|
||||
bindTraitor: bindTraitorTruth,
|
||||
battleAnswers: battleTruthAnswers,
|
||||
bindBattle: bindBattleTruth,
|
||||
cardBlock: transferCardBlock,
|
||||
@@ -33603,7 +33706,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError)
|
||||
if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -37168,22 +37271,13 @@ function applyActionInner(
|
||||
'Traitor decision is not available.',
|
||||
);
|
||||
requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.');
|
||||
const context = traitorTruthContext(g, id);
|
||||
if (context && b.traitorDecisionPromises)
|
||||
b.traitorDecisionPromises = traitorDecisionRule(() => settleTraitorDecisionPromises(context, b.traitorDecisionPromises!, action.call as boolean));
|
||||
if (g.nexusCards?.cards || b.ecazOccupy) ensureTraitorDeclarations(g);
|
||||
if (action.call) {
|
||||
const other =
|
||||
b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker;
|
||||
requireRule(
|
||||
!b.plans[other].kwisatz,
|
||||
'Kwisatz Haderach prevents this leader from turning traitor.',
|
||||
);
|
||||
requireRule(
|
||||
matchingTraitor(
|
||||
p.traitors,
|
||||
b.plans[other].leader,
|
||||
cardOf(getPlayer(g, other), b.plans[other].leader),
|
||||
),
|
||||
'You do not hold that leader as a traitor.',
|
||||
);
|
||||
const blocked = traitorCallBlock(g, b, id);
|
||||
requireRule(!blocked, blocked ?? 'This Traitor declaration is unavailable.');
|
||||
}
|
||||
if (action.call && b.traitorDeclarationVersion)
|
||||
b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id));
|
||||
@@ -37491,6 +37585,9 @@ export function viewGame(state: Game, id: string) {
|
||||
...(g.advanced&&g.decision?.kind==='advisor'&&g.decision.player===id
|
||||
? {spiritualAdvisorChoices:spiritualAdvisorChoices(g,id)} : {}),
|
||||
truthMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
|
||||
truthTraitorTargets: g.players.filter(p => { const context = traitorTruthContext(g, p.id); return context && context.submitted === undefined; }).map(p => p.id),
|
||||
truthTraitorAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'traitorCall' &&
|
||||
g.truthtrance.question.target === id ? traitorTruthAnswers(g, me) : null,
|
||||
truthMovementAnswers: g.truthtrance?.stage === 'answer' &&
|
||||
g.truthtrance.question?.kind === 'movement' && g.truthtrance.question.target === id
|
||||
? movementTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
@@ -38659,6 +38756,9 @@ export function viewGame(state: Game, id: string) {
|
||||
? { [id]: b.plans[id] }
|
||||
: {},
|
||||
traitorSubmitted: Object.keys(b.traitorCalls),
|
||||
traitorCallChoices: (() => { const context = traitorTruthContext(g, id); return context
|
||||
? traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? [])) : null; })(),
|
||||
ownTraitorPromises: (b.traitorDecisionPromises ?? []).filter(promise => promise.player === id),
|
||||
traitorVoters: traitorVoters(g, b),
|
||||
preparation: b.preparation && g.homeworldOccupationPreview && b.preparation.kind === 'voice'
|
||||
? { ...b.preparation, blocked: homeworldRule(() => quoteOccupiedHomeworldVoice(g,
|
||||
|
||||
@@ -69,7 +69,7 @@ export function nexusTraitorReturnAction(
|
||||
(pending.mode === 'secretAlly' && game.phase !== 8) ||
|
||||
cards.length !== pending.count ||
|
||||
new Set(cards).size !== cards.length ||
|
||||
!cards.every((id) => pending.choices.some((choice) => choice.id === id))
|
||||
!cards.every((id) => pending.choices.some((choice) => choice.id === id && !choice.blocked))
|
||||
)
|
||||
return null;
|
||||
return { type: 'nexusTraitorReturn', event, cards: [...cards] };
|
||||
@@ -118,7 +118,7 @@ export function nexusTraitorBotActions(game: GameView): Action[] {
|
||||
game.status !== 'playing'
|
||||
)
|
||||
return [];
|
||||
const cards = [...pending.choices]
|
||||
const cards = pending.choices.filter(choice => !choice.blocked)
|
||||
.sort(
|
||||
(one, two) =>
|
||||
returnPreference(game, one.id) - returnPreference(game, two.id),
|
||||
|
||||
+25
-1
@@ -2997,6 +2997,29 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, compound privacy, bots, JSON and production SQLite restoration; broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-traitor-decision',
|
||||
title: 'Truthtrance: this battle’s Traitor declaration',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind an unsubmitted native voter’s original declaration after both battle plans reveal.',
|
||||
steps: [
|
||||
'Choose Bind this battle’s Traitor declaration, then an actual voter who has not submitted. The public question names the current revealed battle. Already submitted Yes and No votes cannot be reopened; future unrevealed-leader questions remain separate.',
|
||||
'Yes requires a legal Reveal against the opposing revealed leader; No requires decline. Native matching Traitor identity, Kwisatz protection and voter/beneficiary rules remain. Face Dancers do not count as ordinary Traitors. Only the respondent sees feasible answers. This current-choice prototype uses held matching eligibility; unknown future identity draws and broader future preparations remain unfinished.',
|
||||
'The original declaration fulfills the answer before any later Harkonnen-ally Karama response or Nexus betrayal. A prevented effect does not make that completed declaration untruthful. The answer does not guarantee successful betrayal.',
|
||||
'Harkonnen Cunning may still draw and return an ordinary legal identity. A live promised Reveal protects the matching card from voluntary return, with a private reason and other original choices available. No and already fulfilled declarations do not impose that custody restriction. The native pending return resumes after Truthtrance.',
|
||||
'Persistent answer and vote notices use the same legal choices as the server. Four minimal policies honor the declared choice; strategy and calibration remain deferred. Current-decision composition is explicit software policy under the general current-turn rule, not a publisher Traitor-specific FAQ. Full game/module and final acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'battle', 'card-karama', 'nexus-harkonnen-betrayal', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Original event/voter/beneficiary/opponent/leader binding, declaration-time fulfillment and native matching eligibility are connected. Harkonnen Cunning retains unfulfilled matching custody while completed declarations keep ordinary return rights.', evidence: ['game/traitor-decision-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'game/nexus-traitor-options.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public eligible targets, private definite answers, persistent vote notices, both native/ally vote panels and private blocked return reasons are connected. Busy callbacks do nothing and a stale blocked checkbox may be unchecked.', evidence: ['components/truthtrance.tsx', 'components/traitor-decision-promises.tsx', 'components/game-table.tsx', 'components/nexus-traitors.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing profiles consume private legal answers and declarations; the shared Nexus return policy excludes a forbidden identity. No strength tuning or general freeform interpretation is added.', evidence: ['game/bots.ts', 'game/nexus-traitor-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Current declaration versus successful effect, native eligibility and Cunning custody boundaries are explicit, with wider future promises and final acceptance open.', evidence: ['docs/TRUTHTRANCE_TRAITOR_DECISIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ 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-movement',
|
||||
title: 'Truthtrance: ground-movement promises',
|
||||
@@ -3113,7 +3136,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan 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 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.',
|
||||
@@ -3123,6 +3146,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.',
|
||||
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
|
||||
'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.',
|
||||
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. 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.',
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
export type TraitorDecisionContext = {
|
||||
turn: number;
|
||||
battle: string;
|
||||
player: string;
|
||||
beneficiary: string;
|
||||
target: string;
|
||||
leader: string | null;
|
||||
canReveal: boolean;
|
||||
submitted?: boolean;
|
||||
};
|
||||
|
||||
/** Public source of the original declaration, separate from its later effect. */
|
||||
export type TraitorDecisionPromise = Omit<TraitorDecisionContext, 'canReveal' | 'submitted'> & {
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
|
||||
export class TraitorDecisionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TraitorDecisionError';
|
||||
}
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
const turnNumber = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) > 0;
|
||||
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
|
||||
function requireDecision(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new TraitorDecisionError(message);
|
||||
}
|
||||
|
||||
/** Reject private card/eligibility fields rather than retaining them in history. */
|
||||
export function validateTraitorDecisionPromise(value: unknown): value is TraitorDecisionPromise {
|
||||
if (!record(value)) return false;
|
||||
const required = ['answer', 'asker', 'battle', 'beneficiary', 'leader', 'player', 'target', 'turn'];
|
||||
if (!required.every(key => Object.hasOwn(value, key)) ||
|
||||
Object.keys(value).some(key => !required.includes(key) && key !== 'fulfilled' && key !== 'released')) return false;
|
||||
return turnNumber(value.turn) && identifier(value.battle) && identifier(value.player) &&
|
||||
identifier(value.beneficiary) && identifier(value.target) &&
|
||||
(value.leader === null || identifier(value.leader)) && identifier(value.asker) &&
|
||||
typeof value.answer === 'boolean' && optionalBoolean(value.fulfilled) && optionalBoolean(value.released);
|
||||
}
|
||||
|
||||
function validateContext(context: TraitorDecisionContext) {
|
||||
requireDecision(record(context) && turnNumber(context.turn) && identifier(context.battle) &&
|
||||
identifier(context.player) && identifier(context.beneficiary) && identifier(context.target) &&
|
||||
(context.leader === null || identifier(context.leader)) && typeof context.canReveal === 'boolean' &&
|
||||
optionalBoolean(context.submitted),
|
||||
'Retain the current public Traitor decision source and native eligibility.');
|
||||
}
|
||||
|
||||
function validatePromises(promises: readonly TraitorDecisionPromise[]) {
|
||||
requireDecision(Array.isArray(promises) && promises.every(validateTraitorDecisionPromise),
|
||||
'The Traitor decision promise has an invalid public source.');
|
||||
}
|
||||
|
||||
function liveSource(context: TraitorDecisionContext, promise: TraitorDecisionPromise) {
|
||||
return !promise.fulfilled && !promise.released && promise.turn === context.turn &&
|
||||
promise.battle === context.battle && promise.player === context.player &&
|
||||
promise.beneficiary === context.beneficiary && promise.target === context.target && promise.leader === context.leader;
|
||||
}
|
||||
|
||||
/** Current-turn decision composition. Native voters, matching physical cards,
|
||||
* ally benefit and Kwisatz Haderach eligibility remain the caller's authority. */
|
||||
export function traitorDecisionChoices(
|
||||
context: TraitorDecisionContext,
|
||||
promises: readonly TraitorDecisionPromise[],
|
||||
): boolean[] {
|
||||
validateContext(context);
|
||||
validatePromises(promises);
|
||||
if (context.submitted !== undefined) return [];
|
||||
const baseline = context.canReveal ? [false, true] : [false];
|
||||
return baseline.filter(choice => promises.every(promise =>
|
||||
!liveSource(context, promise) || promise.answer === choice));
|
||||
}
|
||||
|
||||
export function bindTraitorDecisionPromise(
|
||||
context: TraitorDecisionContext,
|
||||
promises: readonly TraitorDecisionPromise[],
|
||||
asker: string,
|
||||
answer: boolean,
|
||||
): TraitorDecisionPromise[] {
|
||||
requireDecision(identifier(asker) && typeof answer === 'boolean', 'Choose a definite Traitor decision answer and its asker.');
|
||||
requireDecision(traitorDecisionChoices(context, promises).includes(answer),
|
||||
'This answer has no compatible current Traitor declaration.');
|
||||
return [...promises.map(promise => ({ ...promise })), {
|
||||
turn: context.turn, battle: context.battle, player: context.player,
|
||||
beneficiary: context.beneficiary, target: context.target, leader: context.leader, asker, answer,
|
||||
}];
|
||||
}
|
||||
|
||||
/** Call before assigning the actual declaration. A later prevented effect does
|
||||
* not retract a declaration or undo its fulfilled promise. */
|
||||
export function settleTraitorDecisionPromises(
|
||||
context: TraitorDecisionContext,
|
||||
promises: readonly TraitorDecisionPromise[],
|
||||
call: boolean,
|
||||
): TraitorDecisionPromise[] {
|
||||
requireDecision(typeof call === 'boolean', 'Declare whether to reveal your Traitor.');
|
||||
requireDecision(traitorDecisionChoices(context, promises).includes(call),
|
||||
'This Traitor declaration would break an accepted answer or its current opportunity.');
|
||||
return promises.map(promise => liveSource(context, promise)
|
||||
? { ...promise, fulfilled: true } : { ...promise });
|
||||
}
|
||||
|
||||
/** Only native loss of the still-current Yes capability releases it. Source
|
||||
* mismatch and original producer integrity remain with the battle engine. */
|
||||
export function reconcileTraitorDecisionPromises(
|
||||
context: TraitorDecisionContext,
|
||||
promises: readonly TraitorDecisionPromise[],
|
||||
): TraitorDecisionPromise[] {
|
||||
validateContext(context);
|
||||
validatePromises(promises);
|
||||
return promises.map(promise => !context.canReveal && promise.answer && liveSource(context, promise)
|
||||
? { ...promise, released: true } : { ...promise });
|
||||
}
|
||||
+21
-1
@@ -55,6 +55,7 @@ export type TruthFact =
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
||||
| ({ kind: 'movement'; target: string } & MovementClaim)
|
||||
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
||||
| { kind: 'mixedShipment'; target: string; mixed: MixedShipmentExpression }
|
||||
@@ -205,6 +206,14 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.players.some((p) => p.id === v.target),
|
||||
'Ask one other player.',
|
||||
);
|
||||
if (v.kind === 'traitorCall') {
|
||||
check(Object.keys(v).sort().join(',') === 'battle,kind,target,territory' &&
|
||||
g.status === 'playing' && g.phase === 6 && g.battle?.revealed &&
|
||||
typeof v.battle === 'string' && v.battle === g.battle.event &&
|
||||
v.territory === g.battle.territory && g.battle.traitorCalls[v.target as string] === undefined,
|
||||
'Choose the current revealed battle and a still-unsubmitted Traitor decision.');
|
||||
return { kind: 'traitorCall', target: v.target as string, battle: v.battle, territory: v.territory as string };
|
||||
}
|
||||
if (v.kind === 'movement') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
|
||||
'Supply one ground-movement destination, minimum and respondent.');
|
||||
@@ -317,7 +326,7 @@ 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') {
|
||||
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' || window?.question?.kind === 'traitorCall') {
|
||||
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),
|
||||
@@ -421,6 +430,7 @@ export function truthQuestionText(
|
||||
locationName: (id: string) => string,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
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?`;
|
||||
if (q.kind === 'shipment')
|
||||
return `Will you ${shipmentClaimText(q)} this turn?`;
|
||||
@@ -465,6 +475,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;
|
||||
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
|
||||
bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void;
|
||||
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
|
||||
bindBattle: (
|
||||
g: Game,
|
||||
@@ -581,6 +593,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 === 'traitorCall')
|
||||
check(effects.traitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!).length,
|
||||
'Choose an actual unsubmitted native Traitor voter.');
|
||||
w.stage = 'answer';
|
||||
const target = g.players.find(
|
||||
(other) => other.id === w.question!.target,
|
||||
@@ -628,6 +643,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 === 'traitorCall') {
|
||||
check(effects.traitorAnswers && effects.bindTraitor && effects.traitorAnswers(g, p).includes(a.answer),
|
||||
'Choose an answer compatible with your current legal Traitor declaration and earlier answers.');
|
||||
if (a.answer !== 'unknown') effects.bindTraitor(g, p, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'battlePlan') {
|
||||
check(
|
||||
effects.battleAnswers(g, p, q.claim).includes(a.answer),
|
||||
|
||||
Reference in new issue
Block a user