diff --git a/components/game-table.tsx b/components/game-table.tsx index 89c447d3..f38f48e9 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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 && ( )} + {g.status === 'playing' && } {g.status === 'playing' && } {g.guildBetrayalReaction ? ( diff --git a/components/revival-claim-fields.tsx b/components/revival-claim-fields.tsx new file mode 100644 index 00000000..45d515f0 --- /dev/null +++ b/components/revival-claim-fields.tsx @@ -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
+ Future ordinary Revival returns +

Turn {props.value.turn}

+ + + + change({ ...props.value, minimum: event.currentTarget.valueAsNumber })} /> + {error &&

{error}

} +

+ 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. +

+
; +} diff --git a/components/revival-promises.tsx b/components/revival-promises.tsx new file mode 100644 index 00000000..b012469b --- /dev/null +++ b/components/revival-promises.tsx @@ -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 ; +} + +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
+

Your Revival promises

+
    {own.map(promise =>
  • + 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.'} +
  • )}
+

+ 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. +

+

+ 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. + {' '}Revival promise rules. +

+ {waiting &&

Your Revival opportunity is still ahead; this promise remains visible while the current actor finishes.

} + +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 09a1c76f..1a66f0d1 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -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>({ 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({ ))} + ) : w.question!.kind === 'revival' ? ( + <> +

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.

+ {(g.truthRevivalAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'bid' ? ( <>

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.

@@ -330,6 +342,7 @@ export function Truthtrance({ Verified current facts +