Combine current facts with native Traitor decisions
This commit is contained in:
1 parent
812f77e348
commit
13398aa76e
22 files changed
+1512
-17
No files matched your search
@@ -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 ? (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) ||
|
||||
|
||||
Reference in new issue
Block a user