Combine current facts with native Traitor decisions

This commit is contained in:
Quintine committed 2026-10-26 18:52:06 +11:00
1 parent 812f77e348
commit 13398aa76e
22 files changed
+1512 -17

No files matched your search

+5 -1
View File
@@ -1,4 +1,5 @@
'use client';
import { MixedTraitorPromises } from './mixed-traitor-promises';
import { MixedBattlePromises } from './mixed-battle-promises';
import { TraitorDecisionPromises } from './traitor-decision-promises';
import { MovementPromises } from './movement-promises';
@@ -394,7 +395,9 @@ export function GameTable({
)
);
const canDeclineTraitor = traitorBattle?.traitorCallChoices?.includes(false) ?? true;
const noTraitorReason = traitorBattle?.ownTraitorPromises?.some(promise => !promise.answer && !promise.fulfilled && !promise.released)
const noTraitorReason = traitorBattle?.ownTraitorPromises?.some(promise => !promise.answer && !promise.fulfilled && !promise.released) ||
(!canRevealTraitor && canDeclineTraitor && g.mixedTraitorPromises.some(promise =>
promise.battle === traitorBattle?.event && !promise.fulfilled && !promise.released))
? 'Your accepted Truthtrance answer requires declining this Traitor declaration.' : traitorPlan?.kwisatz
? 'Kwisatz Haderach prevents this leader from turning traitor.'
: 'You do not hold the opposing leader as a traitor.';
@@ -2060,6 +2063,7 @@ export function GameTable({
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
+53
View File
@@ -0,0 +1,53 @@
'use client';
import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import {
MIXED_TRAITOR_MAX_DEPTH, MIXED_TRAITOR_MAX_LEAVES, parseTraitorActionClaim, type TraitorActionClaim,
} from '@/game/mixed-traitor-question';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
/** Question authors choose an original native declaration, never its private
* physical eligibility or the branch needed to honor the aggregate answer. */
const config: MixedActionConfig<TraitorActionClaim> = {
prefix: 'mixed-traitor',
legend: 'Mixed current-fact / this battle’s Traitor declaration',
actionLabel: 'Traitor declaration condition',
actionTiming: 'Traitor declaration · this revealed battle',
actionHelp: <>Traitor conditions concern the respondent’s one original Reveal or decline declaration in this revealed battle,
including a native Harkonnen declaration for an ally. Current facts are snapshotted at the answer.
An unknown current fact uses the conservative declaration commitment that guarantees the whole answer;
if no such answer is possible, the answer is I don’t know. A constant whole answer needs no declaration commitment.
The original declaration fulfills a remaining commitment before a later Karama or betrayal response can prevent its effect.
Native voter eligibility and physical Traitor rules still apply.</>,
fallbackError: 'Choose a current fact and a Traitor declaration condition.',
maxLeaves: MIXED_TRAITOR_MAX_LEAVES,
maxDepth: MIXED_TRAITOR_MAX_DEPTH,
newAction: () => ({ call: true }),
parseAction: value => parseTraitorActionClaim(value),
renderAction: ({ value, id, disabled, onChange }) => <>
<label htmlFor={`${id}-call`}>Traitor declaration</label>
<select id={`${id}-call`} value={value.call ? 'reveal' : 'decline'} disabled={disabled}
aria-invalid={typeof value.call !== 'boolean'} onChange={event => {
if (event.currentTarget.value === 'reveal') onChange({ call: true });
if (event.currentTarget.value === 'decline') onChange({ call: false });
}}>
<option value="reveal">Reveal traitor</option>
<option value="decline">Decline traitor</option>
</select>
</>,
};
export function mixedTraitorInputError(value: unknown, game: GameView, target: string): string | null {
return mixedActionInputError(value, game, target, config);
}
export function MixedTraitorFields(props: {
value: MixedActionExpression<TraitorActionClaim>;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: MixedActionExpression<TraitorActionClaim>) => void;
}) {
return MixedActionFields({ ...props, config });
}
+33
View File
@@ -0,0 +1,33 @@
'use client';
import type { GameView } from '@/game/engine';
import { truthQuestionText } from '@/game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
/** Keep only the own original question and whole answer visible. The native
* battle controls own declaration choices; no residual belongs in this panel. */
export function MixedTraitorPromises({ game }: { game: GameView }) {
const own = game.mixedTraitorPromises.filter(promise => promise.player === game.me);
if (!own.length) return null;
const leaderName = (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
: game.allLeaders.find(leader => leader.id === id)?.name ?? id;
return <section className="notice space-y-3" aria-label="Your Truthtrance mixed Traitor promises" id="mixed-traitor-promise-guidance">
<h3>Your mixed Traitor promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}, {game.combatLocations.find(location => location.id === promise.territory)?.name ?? promise.territory}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedTraitorCall',
target: promise.player, battle: promise.battle, territory: promise.territory, mixed: promise.mixed }, leaderName,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}
{promise.released ? ' Released: this whole commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding for this battle’s Traitor declaration.'}
</li>)}</ul>
<p>Current facts are snapshotted at the answer. They create no promise to keep cards, spice or forces afterward.
Only the aggregate answer is shared. Individual fact answers and any reduced declaration commitment remain private.
AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.</p>
<p className="fine">A constant whole answer is complete immediately without a declaration commitment.
A remaining commitment completes at your original native Reveal or decline declaration.
This concerns the declaration: a later Karama or betrayal response can still prevent its effect.
Use the ordinary battle controls for the currently legal declaration choices.
{' '}<a href="/rules#truthtrance-mixed-traitor">Mixed Traitor rules</a>.</p>
</section>;
}
+22 -6
View File
@@ -2,13 +2,29 @@
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
export function TraitorDecisionAnswers({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) {
if (game.truthtrance?.stage !== 'answer' || game.truthtrance.question?.kind !== 'traitorCall' ||
game.truthtrance.question.target !== game.me) return null;
export function TraitorDecisionAnswers(props: { game: GameView; act: (action: Action) => void; busy: boolean }) {
const { game, act } = props, window = game.truthtrance, question = window?.question, battle = game.battle;
if (window?.stage !== 'answer' || (question?.kind !== 'traitorCall' && question?.kind !== 'mixedTraitorCall') ||
question.target !== game.me || !battle?.revealed || question.battle !== battle.event || question.territory !== battle.territory) return null;
const mixed = question.kind === 'mixedTraitorCall';
const answers = mixed ? game.truthMixedTraitorAnswers : game.truthTraitorAnswers;
const actor = game.me, turn = game.turn, event = battle.event, territory = battle.territory;
const source = window.queue[0], sourceSignature = JSON.stringify(source), signature = JSON.stringify(question);
const canAnswer = (answer: 'yes' | 'no' | 'unknown') => !props.busy && props.game === game && props.act === act &&
game.me === actor && game.turn === turn && game.truthtrance === window && window.stage === 'answer' &&
window.queue[0] === source && JSON.stringify(source) === sourceSignature &&
window.question === question && question.target === actor && JSON.stringify(question) === signature &&
game.battle === battle && battle.revealed && battle.event === event && battle.territory === territory &&
question.battle === event && question.territory === territory && battle.traitorVoters.includes(actor) &&
!battle.traitorSubmitted.includes(actor) &&
(mixed ? game.truthMixedTraitorAnswers : game.truthTraitorAnswers) === answers && !!answers?.includes(answer);
return <section aria-label="Your Truthtrance Traitor answer">
<p className="notice">Yes binds Reveal for this original Traitor decision; No binds decline. This concerns your declaration, so a later Karama or betrayal response can still prevent its effect.</p>
{(game.truthTraitorAnswers ?? []).map(answer => <Button key={answer} className="game-action min-h-11" disabled={busy}
onClick={() => { if (!busy) act({ type: 'truthAnswer', answer }); }}>Answer {answer === 'yes' ? 'Yes' : 'No'}</Button>)}
{mixed ? <p className="notice">Answer the whole mixed claim. Current facts are snapshotted at the answer; No makes the whole expression false, not each condition separately.
A constant whole answer needs no declaration commitment. A remaining commitment concerns your original Reveal or decline declaration,
so a later Karama or betrayal response can still prevent its effect.</p>
: <p className="notice">Yes binds Reveal for this original Traitor decision; No binds decline. This concerns your declaration, so a later Karama or betrayal response can still prevent its effect.</p>}
{(answers ?? []).map(answer => <Button key={answer} className="game-action min-h-11" disabled={!canAnswer(answer)}
onClick={() => { if (canAnswer(answer)) act({ type: 'truthAnswer', answer }); }}>Answer {answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}</Button>)}
</section>;
}
+21 -3
View File
@@ -1,5 +1,8 @@
'use client';
import { MixedTraitorFields, mixedTraitorInputError } from './mixed-traitor-fields';
import type { TraitorActionClaim } from '@/game/mixed-traitor-question';
import { MixedBattleFields, mixedBattleInputError } from './mixed-battle-fields';
import type { MixedBattleActionClaim } from '@/game/mixed-battle-question';
@@ -86,7 +89,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -158,6 +161,11 @@ export function Truthtrance({
{ kind: 'action', action: { kind: 'dial', compare: 'eq', value: 0 } },
] });
const mixedBattleError = mixedBattleInputError(mixedBattle, g, target);
const [mixedTraitor, setMixedTraitor] = useState<MixedActionExpression<TraitorActionClaim>>({ kind: 'and', terms: [
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 1 } },
{ kind: 'action', action: { call: true } },
] });
const mixedTraitorError = mixedTraitorInputError(mixedTraitor, g, target);
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({
kind: 'and',
@@ -194,6 +202,7 @@ export function Truthtrance({
claim: planClaim,
}
: kind === 'mixedBattlePlan' ? { kind, target, battle: g.battle?.event ?? null, territory: g.battle?.territory ?? '', mixed: mixedBattle }
: kind === 'mixedTraitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '', mixed: mixedTraitor }
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
: kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement }
: kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression }
@@ -422,7 +431,7 @@ export function Truthtrance({
<div key={answer}>{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}</div>
))}
</>
) : w.question!.kind === 'traitorCall' ? (
) : w.question!.kind === 'traitorCall' || w.question!.kind === 'mixedTraitorCall' ? (
<TraitorDecisionAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'battlePlan' ? (
<>
@@ -547,6 +556,7 @@ export function Truthtrance({
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="mixedBid">Combine current facts and ordinary bids</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
<option value="shipment">
Bind a shipment from reserves
</option>
@@ -562,7 +572,14 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'mixedBattlePlan' ? (
{kind === 'mixedTraitorCall' ? (
<>
<MixedTraitorFields value={mixedTraitor} game={g} target={target}
disabled={busy || !g.truthMixedTraitorTargets.includes(target)} onChange={setMixedTraitor} />
{!g.truthMixedTraitorTargets.includes(target) && <p className="notice">Choose an actual voter with an unsubmitted Traitor decision in this revealed battle.</p>}
<p className="notice" aria-label="Public mixed Traitor question preview">{mixedTraitorError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'mixedBattlePlan' ? (
<>
<MixedBattleFields value={mixedBattle} game={g} target={target}
disabled={busy || !g.truthMixedBattleTargets.includes(target)} onChange={setMixedBattle} />
@@ -795,6 +812,7 @@ export function Truthtrance({
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'mixedTraitorCall' && (!!mixedTraitorError || !g.truthMixedTraitorTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
(kind === 'mixedShipment' &&
(!!invalidMixed || !shipmentAvailable)) ||