Support Truthtrance charity commitments in Basic play

This commit is contained in:
Quintine committed 2026-10-27 19:07:38 +11:00
1 parent 6a31315c95
commit 72ff9db5e8
18 files changed
+815 -5

No files matched your search

+2 -1
View File
@@ -21,7 +21,7 @@ export function CharityClaim(props: Props) {
const blocked = busy || !!game.truthtrance || !!game.nexusTraitors?.pending || game.charity.incomePending ||
!!game.response || !!game.decision || !!game.phaseOpening || !!game.automaticContinuationPending ||
!ownTurn || !!game.charity.orderBlocked;
const canClaim = !blocked && !me.charityClaimed && game.charity.amount > 0;
const canClaim = !blocked && !game.charity.truthBlocked && !me.charityClaimed && game.charity.amount > 0;
const canFinish = !!order && !blocked;
return (
<>
@@ -51,6 +51,7 @@ export function CharityClaim(props: Props) {
{game.charity.homeworld > 0 &&
` Low Homeworld population adds ${game.charity.homeworld} spice directly from the bank${game.charity.multiplier === 2 ? ' after Inflation doubles the bonus' : ''}.`}
</p>
{game.charity.truthBlocked && <p className="notice" data-testid="charity-truth-blocked">{game.charity.truthBlocked}</p>}
<Button
data-testid="charity-claim-submit"
className="game-action"
+4 -1
View File
@@ -181,6 +181,7 @@ import { ChoamCashIn } from './choam-cash-in';
import { ChoamMarket } from './choam-market';
import { BattlePromises } from './battle-promises';
import { Truthtrance, TruthHistory } from './truthtrance';
import { TruthCharityPromise } from './truth-charity-promise';
import {
forceRevivalPrice,
eliteRevivalRemaining,
@@ -2077,6 +2078,7 @@ export function GameTable({
{g.status === 'playing' && g.phase < 5 && (
<ShipmentPromises game={g} act={act} busy={busy} />
)}
{g.status === 'playing' && <TruthCharityPromise game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BribeExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBribePromises game={g} send={act} busy={busy} />}
@@ -5839,6 +5841,7 @@ export function GameTable({
)}
</>
)}
{g.phase === 2 && g.truthCharityGuide && <p className="notice">Claim Charity before finishing your Truthtrance Yes.</p>}
{[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) && !(g.phase === 2 && g.charityOrder) &&
actionButton(
g.ready.includes(me.id)
@@ -5847,7 +5850,7 @@ export function GameTable({
? 'Accept spice blow'
: 'Ready for next phase',
{ type: 'ready' },
g.ready.includes(me.id),
g.ready.includes(me.id) || (g.phase === 2 && !!g.truthCharityGuide),
)}
</>
)}
+49
View File
@@ -0,0 +1,49 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { bribeAction } from '@/game/bribe-options';
import { Button } from './ui/button';
type Props = { game: GameView; busy: boolean; send: (action: Action) => void };
/** The server supplies an exact native claim or payment, never a simulated transfer. */
export function TruthCharityPromise({ game, busy, send }: Props) {
const own = game.truthCharityPromises.filter(promise => promise.turn === game.turn && promise.player === game.me);
if (!own.length) return null;
const guide = game.truthCharityGuide, actor = game.me, turn = game.turn, action = guide?.action;
const signature = JSON.stringify(action), event = action?.charityPromiseEvent;
const canAct = () => {
const me = game.players.find(player => player.id === actor);
const recipient = typeof action?.target === 'string' ? game.players.find(player => player.id === action.target) : undefined;
const amount = action?.amount;
const payment = action?.type === 'bribe' && !!me && !!recipient && recipient.id !== actor && recipient.id !== me.ally &&
typeof amount === 'number' && !!bribeAction(game.bribeOptions, recipient.id, amount);
const claim = action?.type === 'charity' && !!me && !me.charityClaimed &&
!game.ready.includes(actor) && !game.charity.truthBlocked && !game.charity.incomePending &&
!game.charity.orderBlocked && game.charity.amount > 0;
return !busy && !!guide && !!action && game.truthCharityGuide === guide && guide.action === action &&
JSON.stringify(action) === signature && typeof event === 'string' && action.charityPromiseTurn === turn &&
game.truthCharityPromises.some(promise => promise.player === actor && promise.turn === turn &&
promise.event === event && promise.answer && !promise.fulfilled && !promise.released) &&
game.status === 'playing' && game.phase === 2 && game.me === actor && game.turn === turn &&
!!me && !me.autopilot && (payment || claim) && !game.truthtrance && !game.response &&
!game.decision && !game.phaseOpening && !game.automaticContinuationPending &&
!game.roomControl?.paused && !game.roomControl?.closed;
};
const recipient = game.players.find(player => player.id === action?.target)?.name ?? 'the quoted recipient';
return <section className="notice space-y-3" aria-label="Your Truthtrance Charity promises" id="charity-promise-guidance">
<h3>Your Charity promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
You answered {promise.answer ? 'Yes' : 'No'} to claiming Charity in this phase.
{' '}{promise.released ? 'Released: this commitment is no longer possible.'
: promise.fulfilled ? 'Fulfilled: this commitment is complete.' : 'This answer remains binding while possible.'}
</li>)}</ul>
<p>Yes requires an actual eligible claim after the answer; No forbids claiming during the rest of this Charity phase. Earlier claims do not count.</p>
{guide && action?.type === 'bribe' && <p className="fine">The quoted payment can make you eligible for Charity. You pay immediately; the other player receives the bribe at the start of Mentat Pause.</p>}
{guide && action && <Button className="game-action min-h-11 whitespace-normal" data-testid="charity-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) send(action); }}>
{action.type === 'bribe' ? `Pay ${typeof action.amount === 'number' ? action.amount : 'the quoted'} spice to ${recipient} to prepare for Charity`
: `Claim ${game.charity.amount} Charity spice to honor your answer`}
</Button>}
</section>;
}
+18 -2
View File
@@ -99,7 +99,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' | 'phaseBid' | 'richeseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
'fact' | 'freeform' | 'charity' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -245,6 +245,7 @@ export function Truthtrance({
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'phaseBid' ? { kind, target, ...phaseBidClaim }
: kind === 'richeseBid' ? { kind, target, turn:g.turn, event:g.richeseAuction?.event ?? '', minimum:bidMinimum }
: kind === 'charity' ? { kind, target, turn:g.turn }
: kind === 'shipment'
? {
kind,
@@ -431,6 +432,13 @@ export function Truthtrance({
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'charity' ? (
<>
<p className="notice">This asks whether you will claim Charity after your answer in this Charity phase. Yes requires a legal claim while it remains possible; No means you cannot claim during this phase. Native eligibility and payment rules still apply.</p>
{(g.truthCharityAnswers ?? []).map(answer => <div key={answer}>
{button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'richeseBid' ? (
<>
<p className="notice">Yes requires a qualifying bid in your one remaining opportunity while possible. No forbids a qualifying bid. Silent amounts remain sealed until all submit; winning still requires payment.</p>
@@ -600,6 +608,7 @@ export function Truthtrance({
<option value="leaderRevival">Bind a named leader revival 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="charity">Bind a Charity claim this phase</option>
<option value="phaseBid">Bind a bid this Bidding phase</option>
<option value="richeseBid">Bind a bid in this Richese lot</option>
<option value="mixedBid">Combine current facts and ordinary bids</option>
@@ -745,6 +754,12 @@ export function Truthtrance({
{!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, name)}</p>
</>
) : kind === 'charity' ? (
<>
<p className="fine">Ask whether the selected player will claim Charity after answering during this phase. Earlier claims do not count; native eligibility and payment still apply.</p>
{!g.truthCharityTargets.includes(target) && <p className="notice">Choose a player with a supported remaining Charity opportunity.</p>}
<p className="notice" aria-label="Public Charity question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'richeseBid' ? (
<>
<RicheseBidPromiseFields minimum={bidMinimum} onMinimum={setBidMinimum} disabled={busy || !g.richeseAuction} method={g.richeseAuction?.method === 'silent' ? 'silent' : 'onceAround'} />
@@ -897,6 +912,7 @@ export function Truthtrance({
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'richeseBid' && (!!richeseBidMinimumError(bidMinimum) || !g.richeseAuction || !g.truthRicheseBidTargets.includes(target))) ||
(kind === 'charity' && !g.truthCharityTargets.includes(target)) ||
(kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) ||
(kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
@@ -921,7 +937,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
<details className="notice truthtrance-history">
<summary>Truthtrance answers · Turn {g.turn}</summary>
<p className="fine">
Supported battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment
Supported Charity, battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment
answers are enforced. Current-turn freeform promises remain binding when possible,
but their automatic enforcement is unfinished.
</p>