Combine current facts with native Revival

This commit is contained in:
Quintine committed 2026-10-26 16:53:27 +11:00
1 parent 6b2dfb84a2
commit 957d5a172f
19 files changed
+1386 -5

No files matched your search

+2
View File
@@ -30,6 +30,7 @@ import { AlliancePromises } from './alliance-promises';
import { BribePromises } from './bribe-promises';
import { BribeExpressionPromises } from './bribe-expression-promises';
import { MixedBribePromises } from './mixed-bribe-promises';
import { MixedRevivalPromises } from './mixed-revival-promises';
import { RevivalPromises } from './revival-promises';
import { RevivalExpressionPromises } from './revival-expression-promises';
import { LeaderRevivalPromises } from './leader-revival-promises';
@@ -2047,6 +2048,7 @@ export function GameTable({
{g.status === 'playing' && <MixedBribePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <AlliancePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedRevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
+91
View File
@@ -0,0 +1,91 @@
'use client';
import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseRevivalActionClaim, type RevivalActionClaim } from '@/game/mixed-revival-question';
import { REVIVAL_EXPRESSION_MAX_DEPTH, REVIVAL_EXPRESSION_MAX_LEAVES } from '@/game/revival-expression-promises';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
import { Input } from './ui/input';
/** Names depend only on the respondent's public faction. Current casualties,
* custody and eligibility never determine which questions can be authored. */
const leaders = (game: GameView, target: string) => {
const faction = game.players.find(player => player.id === target)?.faction;
return [...game.allLeaders.filter(leader => !!faction && leader.faction === faction),
...(game.advanced && faction === 'atreides' ? [{ id: 'kwisatz', name: 'Kwisatz Haderach' }] : [])];
};
const newForces = (): RevivalActionClaim => ({ kind: 'forces', counter: 'total', minimum: 1 });
const config: MixedActionConfig<RevivalActionClaim> = {
prefix: 'mixed-revival',
legend: 'Mixed current-fact / future ordinary Revival claim',
actionLabel: 'ordinary Revival condition',
actionTiming: 'Future ordinary Revival · this turn',
actionHelp: <>Count cumulative future ordinary native force and named-leader returns after the answer this turn.
Smaller force returns add together; each normal or elite physical counter counts once.
Prior returns, requests alone, Ghola, foreign leaders and special card returns do not count.
Original costs, revival cycles, limits and turn order still apply. The claim grants no extra Revival opportunity.
An unknown current fact uses the conservative future commitment that guarantees the whole answer.
If no such value is available, the answer is I don’t know.</>,
fallbackError: 'Choose a current fact and a future ordinary Revival condition.',
maxLeaves: REVIVAL_EXPRESSION_MAX_LEAVES,
maxDepth: REVIVAL_EXPRESSION_MAX_DEPTH,
newAction: newForces,
parseAction: (value, game, target) => {
const action = parseRevivalActionClaim(value);
if (action.kind === 'leader' && !leaders(game, target).some(leader => leader.id === action.leader))
throw new Error('Choose a public native leader for the respondent’s faction.');
return action;
},
renderAction: ({ value, id, game, target, disabled, onChange }) => {
const choices = leaders(game, target);
return <>
<label htmlFor={`${id}-kind`}>Revival condition type</label>
<select id={`${id}-kind`} value={value.kind} disabled={disabled} onChange={event => {
if (event.currentTarget.value === 'forces') onChange(newForces());
if (event.currentTarget.value === 'leader' && choices.length) onChange({ kind: 'leader', leader: choices[0].id });
}}>
<option value="forces">Cumulative physical forces</option>
<option value="leader" disabled={!choices.length}>Named ordinary leader</option>
</select>
{value.kind === 'forces' ? <>
<label htmlFor={`${id}-counter`}>Physical force counters</label>
<select id={`${id}-counter`} value={value.counter} disabled={disabled} onChange={event => {
const counter = event.currentTarget.value;
if (counter === 'total' || counter === 'normal' || counter === 'elite') onChange({ ...value, counter });
}}>
<option value="total">All physical forces</option>
<option value="normal">Normal forces</option>
<option value="elite">Elite forces</option>
</select>
<label htmlFor={`${id}-minimum`}>Minimum future ordinary returns</label>
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required disabled={disabled}
value={Number.isFinite(value.minimum) ? value.minimum : ''}
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 || value.minimum > 20}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
</> : <>
<label htmlFor={`${id}-leader`}>Named leader</label>
<select id={`${id}-leader`} value={choices.some(leader => leader.id === value.leader) ? value.leader : ''}
disabled={disabled} aria-invalid={!choices.some(leader => leader.id === value.leader)} onChange={event => {
const leader = event.currentTarget.value;
if (leader === '' || choices.some(choice => choice.id === leader)) onChange({ ...value, leader });
}}>
<option value="">Choose a named leader</option>
{choices.map(leader => <option key={leader.id} value={leader.id}>{leader.name}</option>)}
</select>
</>}
</>;
},
};
export function mixedRevivalInputError(value: unknown, game: GameView, target: string): string | null {
return mixedActionInputError(value, game, target, config);
}
export function MixedRevivalFields(props: {
value: MixedActionExpression<RevivalActionClaim>;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: MixedActionExpression<RevivalActionClaim>) => void;
}) {
return MixedActionFields({ ...props, config });
}
+47
View File
@@ -0,0 +1,47 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { liveRevivalExpressionPromises } from '@/game/revival-expression-promises';
import { truthQuestionText } from '@/game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { RevivalExpressionPromiseGuideButton } from './revival-expression-promises';
/** Only the original question, aggregate answer and ordinary return progress
* are projected. Never evaluate facts or inspect a compiled future branch. */
export function MixedRevivalPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) {
const game = props.game, own = game.mixedRevivalPromises.filter(promise => promise.player === game.me);
if (!own.length) return null;
const leaderName = (id: string) => id === 'kwisatz' ? 'Kwisatz Haderach'
: id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : game.allLeaders.find(leader => leader.id === id)?.name ?? id;
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
const other = game.revivalOrder?.remaining[0];
const waiting = live && (game.phase < 4 || game.phase === 4 && other && other !== game.me);
const sharedGuideElsewhere = liveRevivalExpressionPromises(game.revivalExpressionPromises, game.me, game.turn).length > 0;
return <section className="notice space-y-3" aria-label="Your Truthtrance mixed Revival promises" id="mixed-revival-promise-guidance">
<h3>Your mixed Revival promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedRevival', target: promise.player,
turn: promise.turn, mixed: promise.mixed }, leaderName,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}
{' '}Future ordinary progress: {promise.progress.total} physical forces ({promise.progress.normal} normal, {promise.progress.elite} elite);
{' '}returned leaders: {promise.progress.leaders.length ? promise.progress.leaders.map(leaderName).join(', ') : 'none'}.
{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 turn.'}
</li>)}</ul>
<p>Current facts are evaluated 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 future branch remain private.
AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
Legal alternatives remain available; no fixed branch is selected for you.</p>
<p className="fine">Physical force progress is cumulative across smaller future ordinary returns; each normal or elite counter counts once.
Only actual ordinary native force and named-leader returns after the answer count.
Prior returns, requests alone, Ghola, foreign leaders and special card returns do not count.
Original costs, revival cycles, limits and turn order still apply. The claim grants no extra Revival opportunity.
A guide may accept an existing current Tleilaxu quote; it does not arrange new consent.
{' '}<a href="/rules#truthtrance-mixed-revival">Mixed Revival rules</a>.</p>
{waiting && <p>Your Revival opportunity is still ahead; {game.phase < 4
? 'continue the current phase and preserve the resources for your whole answer.'
: 'this promise remains visible while the current actor finishes.'}</p>}
{live && !sharedGuideElsewhere && <RevivalExpressionPromiseGuideButton {...props} />}
</section>;
}
+4 -2
View File
@@ -39,7 +39,9 @@ export function RevivalExpressionPromiseGuideButton(props: RevivalExpressionProm
current.status === 'playing' && current.phase === phase && current.me === actor && current.turn === turn && opportunity &&
current.revivalExpressionPromiseGuide === guide && current.revivalExpressionPromiseGuide.phase === phase &&
current.revivalExpressionPromiseGuide.action === action && JSON.stringify(action) === signature && typeof event === 'string' &&
liveRevivalExpressionPromises(current.revivalExpressionPromises, actor, turn).some(promise => promise.event === event) && !!me && !me.autopilot &&
(liveRevivalExpressionPromises(current.revivalExpressionPromises, actor, turn).some(promise => promise.event === event) ||
(current.mixedRevivalPromises ?? []).some(promise => promise.event === event && promise.player === actor &&
promise.turn === turn && !promise.fulfilled && !promise.released)) && !!me && !me.autopilot &&
!current.truthtrance && !current.response && (!current.decision || payment) && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
@@ -92,6 +94,6 @@ export function RevivalExpressionPromises(props: RevivalExpressionPromiseProps)
{waiting && <p>Your Revival opportunity is still ahead; {props.game.phase < 4
? 'continue the current phase and preserve the resources for your whole answer.'
: 'this promise remains visible while the current actor finishes.'}</p>}
<RevivalExpressionPromiseGuideButton {...props} />
{live && <RevivalExpressionPromiseGuideButton {...props} />}
</section>;
}
+26 -2
View File
@@ -1,6 +1,8 @@
'use client';
import { MixedBribeFields, mixedBribeInputError } from './mixed-bribe-fields';
import { MixedRevivalFields, mixedRevivalInputError } from './mixed-revival-fields';
import type { RevivalActionClaim } from '@/game/mixed-revival-question';
import type { BribePaymentClaim } from '@/game/mixed-bribe-question';
import { MixedMovementFields, mixedMovementInputError } from './mixed-movement-fields';
import type { MixedActionExpression } from '@/game/mixed-action-question';
@@ -75,7 +77,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'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -123,6 +125,11 @@ export function Truthtrance({
{ kind: 'action', action: { recipient: g.me, minimum: 3 } },
] });
const mixedBribeError = mixedBribeInputError(mixedBribe, g, target);
const [mixedRevival, setMixedRevival] = useState<MixedActionExpression<RevivalActionClaim>>({ kind: 'and', terms: [
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 4 } },
{ kind: 'action', action: { kind: 'forces', counter: 'total', minimum: 1 } },
] });
const mixedRevivalError = mixedRevivalInputError(mixedRevival, g, target);
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({
kind: 'and',
@@ -162,6 +169,7 @@ export function Truthtrance({
: kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement }
: kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression }
: kind === 'movement' ? { kind, target, ...movement }
: kind === 'mixedRevival' ? { kind, target, turn: g.turn, mixed: mixedRevival }
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression }
: kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim }
: kind === 'alliance' ? { kind, target, ...allianceClaim }
@@ -277,6 +285,13 @@ export function Truthtrance({
</div>
))}
</>
) : w.question!.kind === 'mixedRevival' ? (
<>
<p className="notice">Answer the complete mixed question. Current facts freeze now; only future ordinary returns can fulfill any remaining Revival conditions. No keeps the whole expression false. Native eligibility, costs, limits and turn order still apply. An unknown fact requires a future commitment that guarantees your answer; otherwise choose I don’t know.</p>
{(g.truthMixedRevivalAnswers ?? []).map(answer => <div key={answer}>
{button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'revivalExpression' ? (
<>
<p className="notice">Answer the whole AND/OR expression. Yes requires the complete statement to become true through actual future ordinary returns; No keeps the whole statement false. Legal alternatives remain available and no branch is fixed by this answer.</p>
@@ -470,6 +485,7 @@ export function Truthtrance({
<option value="bribe">Bind ordinary bribe payments this turn</option>
<option value="bribeExpression">Combine ordinary bribe payments with AND/OR</option>
<option value="mixedBribe">Combine current facts and ordinary payments</option>
<option value="mixedRevival">Combine current facts and ordinary Revival</option>
<option value="revivalExpression">Bind combined force and leader revivals</option>
<option value="leaderRevival">Bind a named leader revival this turn</option>
<option value="revival">Bind ordinary force revivals this turn</option>
@@ -489,7 +505,14 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'revivalExpression' ? (
{kind === 'mixedRevival' ? (
<>
<MixedRevivalFields value={mixedRevival} game={g} target={target}
disabled={busy || !g.truthMixedRevivalTargets.includes(target)} onChange={setMixedRevival} />
{!g.truthMixedRevivalTargets.includes(target) && <p className="notice">Choose a supported participant with an ordinary Revival opportunity still ahead this turn.</p>}
<p className="notice" aria-label="Public mixed revival question preview">{mixedRevivalError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'revivalExpression' ? (
<>
<RevivalExpressionFields value={revivalExpression} leaders={leaderRevivalChoices}
disabled={busy || !g.truthRevivalExpressionTargets.includes(target)} onChange={setRevivalExpression} />
@@ -674,6 +697,7 @@ export function Truthtrance({
(kind === 'mixedMovement' && (!!mixedMovementError || !g.truthMixedMovementTargets.includes(target))) ||
(kind === 'movementExpression' && (!!movementExpressionError || !g.truthMovementExpressionTargets.includes(target))) ||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
(kind === 'mixedRevival' && (!!mixedRevivalError || !g.truthMixedRevivalTargets.includes(target))) ||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
(kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) ||