Add cumulative Truthtrance force-revival commitments

This commit is contained in:
Quintine committed 2026-10-26 12:57:18 +11:00
1 parent bd6e5bbf7f
commit 24653799cf
21 files changed
+1540 -56

No files matched your search

+2
View File
@@ -24,6 +24,7 @@ import {
liveShipmentPromises,
} from '@/game/shipment-promises';
import { ShipmentPromises } from './shipment-promises';
import { RevivalPromises } from './revival-promises';
import { BidPromises } from './bid-promises';
import { HomeworldShipment } from './homeworld-shipment';
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
@@ -2031,6 +2032,7 @@ export function GameTable({
{g.status === 'playing' && g.phase < 5 && (
<ShipmentPromises game={g} act={act} busy={busy} />
)}
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { parseRevivalClaim, type RevivalClaim } from '@/game/revival-promises';
import { Input } from './ui/input';
export function revivalClaimInputError(value: unknown): string | null {
try { parseRevivalClaim(value); return null; }
catch (error) { return error instanceof Error ? error.message : 'Choose a whole physical-force minimum from 1 to 20.'; }
}
/** The parent fixes this turn; the editor chooses physical counters and a
* cumulative minimum without reconstructing native revival feasibility. */
export function RevivalClaimFields(props: {
value: RevivalClaim;
onChange: (claim: RevivalClaim) => void;
disabled?: boolean;
}) {
const error = revivalClaimInputError(props.value);
const change = (value: unknown) => {
if (!props.disabled && !revivalClaimInputError(value)) props.onChange(parseRevivalClaim(value));
};
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Ordinary native Revival condition">
<legend>Future ordinary Revival returns</legend>
<p>Turn {props.value.turn}</p>
<label htmlFor="truth-revival-counter">Physical force counters</label>
<select id="truth-revival-counter" value={props.value.counter} disabled={props.disabled}
onChange={event => change({ ...props.value, counter: event.currentTarget.value })}>
<option value="total">All physical forces</option>
<option value="normal">Normal forces</option>
<option value="elite">Elite forces</option>
</select>
<label htmlFor="truth-revival-minimum">Minimum future ordinary returns</label>
<Input id="truth-revival-minimum" type="number" min={1} max={20} step={1} required disabled={props.disabled}
value={Number.isFinite(props.value.minimum) ? props.value.minimum : ''} aria-invalid={!!error}
aria-describedby="truth-revival-help"
onChange={event => change({ ...props.value, minimum: event.currentTarget.valueAsNumber })} />
{error && <p role="alert">{error}</p>}
<p className="fine" id="truth-revival-help">
Count cumulative future ordinary native returns after the answer. Total counts normal and elite counters together;
each physical counter counts once. Earlier returns, card effects, Ghola and Emperor extra revivals do not count.
</p>
</fieldset>;
}
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { liveRevivalPromises, revivalClaimText, revivalPromiseModeSupported } from '@/game/revival-promises';
import { Button } from './ui/button';
type RevivalPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
/** Preserve the server's exact native return/Ready quote. The client checks
* only the live personalized event and current owned action opportunity. */
export function RevivalPromiseGuideButton(props: RevivalPromiseProps) {
const game = props.game, guide = game.revivalPromiseGuide;
if (!guide) return null;
const actor = game.me, turn = game.turn, order = game.revivalOrder;
const action = guide.action, event = action.revivalPromiseEvent, signature = JSON.stringify(action);
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === current.me);
return !props.busy && current === game && revivalPromiseModeSupported(current) &&
current.status === 'playing' && current.phase === 4 && current.me === actor && current.turn === turn &&
!current.ready.includes(actor) && current.revivalOrder === order &&
(!current.revivalOrder || current.revivalOrder.turn === turn && current.revivalOrder.remaining[0] === actor && current.active === actor) &&
current.revivalPromiseGuide === guide && current.revivalPromiseGuide.action === action && JSON.stringify(action) === signature &&
typeof event === 'string' && (action.type === 'revive' || action.type === 'ready') &&
liveRevivalPromises(current.revivalPromises, actor, turn).some(promise => promise.event === event) && !!me && !me.autopilot &&
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return <Button className="game-action min-h-11 whitespace-normal" data-testid="revival-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
{action.type === 'revive'
? `Revive ${typeof action.amount === 'number' ? action.amount : 'the suggested number of'} forces while honoring your answers`
: 'Finish Revival while honoring your answers'}
</Button>;
}
export function RevivalPromises(props: RevivalPromiseProps) {
const own = props.game.revivalPromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
const live = liveRevivalPromises(own, props.game.me, props.game.turn).length > 0;
const other = props.game.revivalOrder?.remaining[0];
const waiting = live && (props.game.phase < 4 || props.game.phase === 4 && other && other !== props.game.me);
return <section className="notice space-y-3" aria-label="Your Truthtrance Revival promises" id="revival-promise-guidance">
<h3>Your Revival promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {revivalClaimText(promise)}.
{' '}Recorded future ordinary returns: {promise.progress} {promise.counter === 'total' ? 'physical' : promise.counter} force{promise.progress === 1 ? '' : 's'}.
{promise.released ? ' Released: this commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for this turn.'}
</li>)}</ul>
<p>
Yes requires reaching the cumulative minimum through future ordinary native returns while it remains possible.
No keeps your cumulative future returns below that minimum, including across smaller split requests.
Prior returns, card effects, Ghola and Emperor extra revivals do not count.
</p>
<p className="fine">
Each normal or elite force counter counts once; total includes both types.
Original revival costs, limits and turn order still apply. Optional modules remain outside this commitment.
{' '}<a href="/rules#truthtrance-revival">Revival promise rules</a>.
</p>
{waiting && <p>Your Revival opportunity is still ahead; this promise remains visible while the current actor finishes.</p>}
<RevivalPromiseGuideButton {...props} />
</section>;
}
+24 -2
View File
@@ -1,5 +1,7 @@
'use client';
import { MovementClaimFields } from './movement-claim-fields';
import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields';
import type { RevivalClaim } from '@/game/revival-promises';
import { BidClaimFields, bidClaimInputError } from './bid-claim-fields';
import type { BidClaim } from '@/game/bid-promises';
import { TraitorDecisionAnswers } from './traitor-decision-promises';
@@ -55,8 +57,10 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival'
>('fact');
const [revivalSelection, setRevivalSelection] = useState<Pick<RevivalClaim, 'counter' | 'minimum'>>({ counter: 'total', minimum: 1 });
const revivalClaim: RevivalClaim = { turn: g.turn, ...revivalSelection };
const [bidMinimum, setBidMinimum] = useState(1);
const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn,
lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum };
@@ -98,6 +102,7 @@ export function Truthtrance({
}
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
: kind === 'movement' ? { kind, target, ...movement }
: kind === 'revival' ? { kind, target, ...revivalClaim }
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'shipment'
? {
@@ -206,6 +211,13 @@ export function Truthtrance({
</div>
))}
</>
) : w.question!.kind === 'revival' ? (
<>
<p className="notice">Yes requires the chosen total of future ordinary force returns while possible. No forbids reaching that total, including split requests. Only completed returns after this answer count. Keep the original revival funding and finish all remaining Yes answers before ending Revival.</p>
{(g.truthRevivalAnswers ?? []).map(answer => <div key={answer}>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'bid' ? (
<>
<p className="notice">Yes requires one qualifying bid in this ordinary lot while possible. No forbids qualifying bids until it closes. A bid does not promise a win; winning still requires the original payment. Keep the funding needed by your answer.</p>
@@ -330,6 +342,7 @@ export function Truthtrance({
Verified current facts
</option>
<option value="movement">Bind ground movement this turn</option>
<option value="revival">Bind ordinary force revivals this phase</option>
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="shipment">
@@ -346,7 +359,15 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'bid' ? (
{kind === 'revival' ? (
<>
<RevivalClaimFields value={revivalClaim} disabled={busy || !g.truthRevivalTargets.includes(target)}
onChange={claim => setRevivalSelection({ counter: claim.counter, minimum: claim.minimum })} />
<p className="fine">Count physical total, normal or elite counters returned by ordinary native Revival after the answer. Smaller requests add together. Earlier returns, Ghola, Emperor-funded extras and other special returns do not count. Current native limits, prices and response choices remain.</p>
{!g.truthRevivalTargets.includes(target) && <p className="notice">Choose a respondent whose native opportunity remains in the current Revival phase and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.</p>}
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName)}</p>
</>
) : kind === 'bid' ? (
<>
<BidClaimFields value={bidClaim} disabled={busy || !g.bidPromiseLot}
onChange={claim => setBidMinimum(claim.minimum)} />
@@ -451,6 +472,7 @@ export function Truthtrance({
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
(kind === 'mixedShipment' &&