Combine current facts with native Revival
This commit is contained in:
1 parent
6b2dfb84a2
commit
957d5a172f
19 files changed
+1386
-5
No files matched your search
@@ -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} />}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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))) ||
|
||||
|
||||
Reference in new issue
Block a user