Add cumulative Truthtrance bribe-payment commitments
This commit is contained in:
1 parent
7fc21113e3
commit
335b4ce638
18 files changed
+1453
-21
No files matched your search
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { parseBribeClaim, type BribeClaim } from '@/game/bribe-promises';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type Recipient = { id: string; name: string };
|
||||
export function bribeClaimInputError(value: unknown, recipients?: readonly Recipient[]): string | null {
|
||||
try {
|
||||
const claim = parseBribeClaim(value);
|
||||
return recipients && !recipients.some(recipient => recipient.id === claim.recipient)
|
||||
? 'Choose one of the current bribe recipients.' : null;
|
||||
} catch (error) { return error instanceof Error ? error.message : 'Choose a recipient and a positive safe whole minimum.'; }
|
||||
}
|
||||
|
||||
/** The parent supplies this turn and its current public recipient choices. */
|
||||
export function BribeClaimFields(props: {
|
||||
value: BribeClaim;
|
||||
onChange: (claim: BribeClaim) => void;
|
||||
recipients: readonly Recipient[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const error = bribeClaimInputError(props.value, props.recipients);
|
||||
const change = (value: unknown) => {
|
||||
if (!props.disabled && !bribeClaimInputError(value, props.recipients)) props.onChange(parseBribeClaim(value));
|
||||
};
|
||||
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Future ordinary bribe condition">
|
||||
<legend>Future ordinary bribe payments</legend>
|
||||
<p>Turn {props.value.turn}</p>
|
||||
<label htmlFor="truth-bribe-recipient">Bribe recipient</label>
|
||||
<select id="truth-bribe-recipient" disabled={props.disabled}
|
||||
value={props.recipients.some(recipient => recipient.id === props.value.recipient) ? props.value.recipient : ''}
|
||||
onChange={event => change({ ...props.value, recipient: event.currentTarget.value })}>
|
||||
<option value="">Choose a recipient</option>
|
||||
{props.recipients.map(recipient => <option key={recipient.id} value={recipient.id}>{recipient.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor="truth-bribe-minimum">Minimum future ordinary bribe payments in spice</label>
|
||||
<Input id="truth-bribe-minimum" type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required disabled={props.disabled}
|
||||
value={Number.isFinite(props.value.minimum) ? props.value.minimum : ''} aria-invalid={!!error}
|
||||
aria-describedby="truth-bribe-help"
|
||||
onChange={event => change({ ...props.value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<p className="fine" id="truth-bribe-help">
|
||||
Count cumulative future ordinary bribe payments to this recipient after the answer; smaller payments add together.
|
||||
Earlier payments, gifts and ally aid do not count. The sender pays now; the recipient can spend the payment at Mentat Pause.
|
||||
</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bribeAction } from '@/game/bribe-options';
|
||||
import { bribeClaimText, bribePromiseModeSupported, liveBribePromises } from '@/game/bribe-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type BribePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
|
||||
/** Native timing/funding remains in the current projected bribe options. An
|
||||
* ordinary bribe may be made outside this sender's phase action turn. */
|
||||
export function BribePromiseGuideButton(props: BribePromiseProps) {
|
||||
const game = props.game, guide = game.bribePromiseGuide;
|
||||
if (!guide) return null;
|
||||
const actor = game.me, turn = game.turn, action = guide.action;
|
||||
const event = action.bribePromiseEvent, signature = JSON.stringify(action);
|
||||
const canAct = () => {
|
||||
const current = props.game, me = current.players.find(player => player.id === current.me), options = current.bribeOptions;
|
||||
const target = typeof action.target === 'string' ? current.players.find(player => player.id === action.target) : undefined;
|
||||
const limit = options.targets.find(recipient => recipient.id === action.target);
|
||||
return !props.busy && current === game && bribePromiseModeSupported(current) && current.status === 'playing' &&
|
||||
Number.isSafeInteger(current.phase) && current.phase >= 0 && current.phase <= 7 && current.turn === turn && current.me === actor &&
|
||||
current.bribePromiseGuide === guide && current.bribePromiseGuide.action === action && JSON.stringify(action) === signature &&
|
||||
action.type === 'bribe' && typeof event === 'string' &&
|
||||
liveBribePromises(current.bribePromises, actor, turn).some(promise => promise.event === event) &&
|
||||
!!me && !me.autopilot && !!target && target.id !== actor && target.id !== me.ally &&
|
||||
options.blocked === null && Number.isSafeInteger(options.available) &&
|
||||
typeof action.amount === 'number' && action.amount <= options.available &&
|
||||
!!limit && Number.isSafeInteger(limit.maximum) && action.amount <= limit.maximum &&
|
||||
!!bribeAction(options, target.id, action.amount) &&
|
||||
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
|
||||
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
const recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient';
|
||||
return <Button className="game-action min-h-11 whitespace-normal" data-testid="bribe-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
|
||||
Pay {typeof action.amount === 'number' ? action.amount : 'the quoted'} spice to {recipient} while honoring your answers
|
||||
</Button>;
|
||||
}
|
||||
|
||||
export function BribePromises(props: BribePromiseProps) {
|
||||
const own = props.game.bribePromises.filter(promise => promise.player === props.game.me);
|
||||
if (!own.length) return null;
|
||||
const name = (id: string) => props.game.players.find(player => player.id === id)?.name ?? 'the named recipient';
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance bribe promises" id="bribe-promise-guidance">
|
||||
<h3>Your bribe promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {bribeClaimText(promise, name)}.
|
||||
{' '}Recorded future ordinary payments to {name(promise.recipient)}: {promise.progress} spice.
|
||||
{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 while it remains possible; preserve the needed spendable spice.
|
||||
No forbids reaching that minimum during the rest of this turn. Smaller payments to the named recipient add together.
|
||||
Prior payments, gifts and ally aid do not count.
|
||||
</p>
|
||||
<p className="fine">
|
||||
The sender pays immediately. The recipient can spend the payment only at Mentat Pause.
|
||||
Ordinary bribes can be paid out of turn when the current bribe controls permit them; original timing and payment rules still apply.
|
||||
{' '}<a href="/rules#truthtrance-bribe">Bribe promise rules</a>.
|
||||
</p>
|
||||
<BribePromiseGuideButton {...props} />
|
||||
</section>;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
liveShipmentPromises,
|
||||
} from '@/game/shipment-promises';
|
||||
import { ShipmentPromises } from './shipment-promises';
|
||||
import { BribePromises } from './bribe-promises';
|
||||
import { RevivalPromises } from './revival-promises';
|
||||
import { BidPromises } from './bid-promises';
|
||||
import { HomeworldShipment } from './homeworld-shipment';
|
||||
@@ -2032,6 +2033,7 @@ export function GameTable({
|
||||
{g.status === 'playing' && g.phase < 5 && (
|
||||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.status === 'playing' && <BribePromises 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} />}
|
||||
{g.guildBetrayalReaction ? (
|
||||
|
||||
+35
-10
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
import { MovementClaimFields } from './movement-claim-fields';
|
||||
import { BribeClaimFields, bribeClaimInputError } from './bribe-claim-fields';
|
||||
import type { BribeClaim } from '@/game/bribe-promises';
|
||||
import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields';
|
||||
import type { RevivalClaim } from '@/game/revival-promises';
|
||||
import { BidClaimFields, bidClaimInputError } from './bid-claim-fields';
|
||||
@@ -57,8 +59,12 @@ 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'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe'
|
||||
>('fact');
|
||||
const [bribeSelection, setBribeSelection] = useState<Pick<BribeClaim, 'recipient' | 'minimum'>>({ recipient: g.me, minimum: 3 });
|
||||
const bribeClaim: BribeClaim = { turn: g.turn, ...bribeSelection };
|
||||
const bribeRespondent = g.players.find(player => player.id === target);
|
||||
const bribeRecipients = g.players.filter(player => player.id !== target && player.id !== bribeRespondent?.ally);
|
||||
const [revivalSelection, setRevivalSelection] = useState<Pick<RevivalClaim, 'counter' | 'minimum'>>({ counter: 'total', minimum: 1 });
|
||||
const revivalClaim: RevivalClaim = { turn: g.turn, ...revivalSelection };
|
||||
const [bidMinimum, setBidMinimum] = useState(1);
|
||||
@@ -102,6 +108,7 @@ export function Truthtrance({
|
||||
}
|
||||
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
|
||||
: kind === 'movement' ? { kind, target, ...movement }
|
||||
: kind === 'bribe' ? { kind, target, ...bribeClaim }
|
||||
: kind === 'revival' ? { kind, target, ...revivalClaim }
|
||||
: kind === 'bid' ? { kind, target, ...bidClaim }
|
||||
: kind === 'shipment'
|
||||
@@ -175,7 +182,7 @@ export function Truthtrance({
|
||||
</p>
|
||||
{w.question && (
|
||||
<blockquote>
|
||||
<p>{truthQuestionText(w.question, leaderName, locationName)}</p>
|
||||
<p>{truthQuestionText(w.question, leaderName, locationName, name)}</p>
|
||||
<p className="fine">Asked of {name(w.question.target)}</p>
|
||||
</blockquote>
|
||||
)}
|
||||
@@ -211,6 +218,13 @@ export function Truthtrance({
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : w.question!.kind === 'bribe' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires cumulative ordinary bribe payments to this named recipient while possible. No keeps all later payments below the minimum. Earlier payments, gifts and allied aid do not count. You pay now; the recipient spends the receipt at Mentat Pause.</p>
|
||||
{(g.truthBribeAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</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>
|
||||
@@ -342,6 +356,7 @@ export function Truthtrance({
|
||||
Verified current facts
|
||||
</option>
|
||||
<option value="movement">Bind ground movement this turn</option>
|
||||
<option value="bribe">Bind ordinary bribe payments this turn</option>
|
||||
<option value="revival">Bind ordinary force revivals this turn</option>
|
||||
<option value="bid">Bind a bid in this ordinary lot</option>
|
||||
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
|
||||
@@ -359,14 +374,22 @@ export function Truthtrance({
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{kind === 'revival' ? (
|
||||
{kind === 'bribe' ? (
|
||||
<>
|
||||
<BribeClaimFields value={bribeClaim} recipients={bribeRecipients} disabled={busy || !g.truthBribeTargets.includes(target)}
|
||||
onChange={claim => setBribeSelection({ recipient: claim.recipient, minimum: claim.minimum })} />
|
||||
<p className="fine">Count cumulative ordinary payer payments after this answer, before this turn ends. Keep the current spendable spice needed by every Yes. Actual bribe timing and deferred recipient receipts remain; optional modules and future-income preparation remain unfinished.</p>
|
||||
{!g.truthBribeTargets.includes(target) && <p className="notice">Choose a supported participant before Mentat Pause and finish pending decisions.</p>}
|
||||
<p className="notice" aria-label="Public bribe question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'revival' ? (
|
||||
<>
|
||||
<RevivalClaimFields value={revivalClaim} disabled={busy || !g.truthRevivalTargets.includes(target)}
|
||||
onChange={claim => setRevivalSelection({ counter: claim.counter, minimum: claim.minimum })} />
|
||||
{g.phase < 4 && <p className="notice">Revival comes later this turn. Keep its required funding; actual Charity or an owned auction payment may prepare the return. Unknown future draws, gifts and casualties are not assumed.</p>}
|
||||
<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 Revival opportunity remains this turn 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>
|
||||
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'bid' ? (
|
||||
<>
|
||||
@@ -374,13 +397,13 @@ export function Truthtrance({
|
||||
onChange={claim => setBidMinimum(claim.minimum)} />
|
||||
<p className="fine">One accepted bid from now until this lot closes. Earlier bids do not count. A larger opposing bid may make Yes impossible; your own pass, lower bid or voluntary spending cannot evade it.</p>
|
||||
{!g.truthBidTargets.includes(target) && <p className="notice">Choose a bidder with an unused opportunity in the current ordinary lot and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported; other auctions and module compositions remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public bid question preview">{truthQuestionText(question, leaderName, locationName)}</p>
|
||||
<p className="notice" aria-label="Public bid question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'traitorCall' ? (
|
||||
<>
|
||||
<p className="notice">Choose a still-unsubmitted voter in the current revealed battle. A definite answer binds that player’s original declaration, without reopening any sealed vote or promising that a later response will allow it.</p>
|
||||
{!g.truthTraitorTargets.includes(target) && <p>Reveal both plans and choose a voter who has not submitted their Traitor decision.</p>}
|
||||
{g.battle?.revealed && <p className="notice" aria-label="Public Traitor question preview">{truthQuestionText(question, leaderName, locationName)}</p>}
|
||||
{g.battle?.revealed && <p className="notice" aria-label="Public Traitor question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>}
|
||||
</>
|
||||
) : kind === 'movement' ? (
|
||||
<>
|
||||
@@ -390,7 +413,7 @@ export function Truthtrance({
|
||||
{g.advanced && <p className="fine">Advanced movement counts physical forces, including each elite once. Hajr remains available only immediately after a completed movement and its required arrival decisions.</p>}
|
||||
<p className="fine">An owned No-Field may prepare a route through its normal shipment, movement or voluntary reveal. The concealed marker does not count toward the promised physical minimum; revelation uses only available reserves.</p>
|
||||
{!g.truthMovementTargets.includes(target) && <p className="notice">Choose a supported Basic or Advanced respondent with declared native families and an unused turn this round, and finish pending decisions. Optional modules remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public movement question preview">{truthQuestionText(question, leaderName, locationName)}</p>
|
||||
<p className="notice" aria-label="Public movement question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'shipment' || kind === 'mixedShipment' ? (
|
||||
|
||||
@@ -424,7 +447,7 @@ export function Truthtrance({
|
||||
</p>
|
||||
)}
|
||||
{kind === 'mixedShipment' && <p className="notice" aria-label="Public grouped question preview">
|
||||
{invalidMixed ?? truthQuestionText(question, leaderName, locationName)}
|
||||
{invalidMixed ?? truthQuestionText(question, leaderName, locationName, name)}
|
||||
</p>}
|
||||
</>
|
||||
) : kind === 'battlePlan' ? (
|
||||
@@ -473,6 +496,7 @@ export function Truthtrance({
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
|
||||
(kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) ||
|
||||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
|
||||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
|
||||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
|
||||
@@ -495,8 +519,8 @@ export function TruthHistory({ game: g }: { game: GameView }) {
|
||||
<details className="notice truthtrance-history">
|
||||
<summary>Truthtrance answers · Turn {g.turn}</summary>
|
||||
<p className="fine">
|
||||
Structured battle-plan, ordinary-bid and supported reserve-shipment answers are
|
||||
enforced. Current-turn freeform promises remain binding when possible,
|
||||
Supported battle-plan, bid, revival, bribe, movement and reserve-shipment
|
||||
answers are enforced. Current-turn freeform promises remain binding when possible,
|
||||
but their automatic enforcement is unfinished.
|
||||
</p>
|
||||
<ol>
|
||||
@@ -509,6 +533,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
|
||||
? 'Cheap Hero / Heroine'
|
||||
: (g.allLeaders.find((l) => l.id === id)?.name ?? id),
|
||||
id => g.combatLocations.find(place => place.id === id)!.name,
|
||||
name,
|
||||
)}
|
||||
</p>
|
||||
<strong>
|
||||
|
||||
Reference in new issue
Block a user