Combine current facts with native bid declarations

This commit is contained in:
Quintine committed 2026-10-26 17:05:16 +11:00
1 parent 957d5a172f
commit 0d979e4f8b
20 files changed
+1214 -7

No files matched your search

+6 -4
View File
@@ -20,8 +20,10 @@ export function BidPromiseGuideButton(props: BidPromiseProps) {
current.turn === turn && current.bidPromiseLot?.turn === turn && current.bidPromiseLot.lot === number &&
current.bidPromiseGuide === guide && current.bidPromiseGuide.action === action && JSON.stringify(action) === signature &&
typeof event === 'string' && (action.type === 'bid' || action.type === 'passBid') &&
current.bidPromises.some(promise => promise.event === event && promise.player === actor &&
promise.turn === turn && promise.lot === number && !promise.fulfilled && !promise.released) &&
(current.bidPromises.some(promise => promise.event === event && promise.player === actor &&
promise.turn === turn && promise.lot === number && !promise.fulfilled && !promise.released) ||
current.mixedBidPromises.some(promise => promise.event === event && promise.player === actor &&
promise.turn === turn && promise.lot === number && !promise.fulfilled && !promise.released)) &&
!!me && !me.autopilot && !current.truthtrance && !current.response && !current.decision &&
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
@@ -37,7 +39,7 @@ export function BidPromises(props: BidPromiseProps) {
const own = props.game.bidPromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
const waiting = own.some(promise => !promise.fulfilled && !promise.released &&
promise.turn === props.game.turn && promise.lot === props.game.bidPromiseLot?.lot);
props.game.bidPromiseLot?.turn === props.game.turn && promise.turn === props.game.turn && promise.lot === props.game.bidPromiseLot.lot);
return <section className="notice space-y-3" aria-label="Your Truthtrance bid promises" id="bid-promise-guidance">
<h3>Your bid promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
@@ -57,6 +59,6 @@ export function BidPromises(props: BidPromiseProps) {
Ordinary bids keep their existing funding and payment rules.
</p>
{waiting && props.game.auction?.active !== props.game.me && <p>Your next bid opportunity is still ahead; this promise remains visible while others bid.</p>}
<BidPromiseGuideButton {...props} />
{waiting && <BidPromiseGuideButton {...props} />}
</section>;
}
+2
View File
@@ -31,6 +31,7 @@ import { BribePromises } from './bribe-promises';
import { BribeExpressionPromises } from './bribe-expression-promises';
import { MixedBribePromises } from './mixed-bribe-promises';
import { MixedRevivalPromises } from './mixed-revival-promises';
import { MixedBidPromises } from './mixed-bid-promises';
import { RevivalPromises } from './revival-promises';
import { RevivalExpressionPromises } from './revival-expression-promises';
import { LeaderRevivalPromises } from './leader-revival-promises';
@@ -2052,6 +2053,7 @@ export function GameTable({
{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} />}
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
+48
View File
@@ -0,0 +1,48 @@
'use client';
import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { MIXED_BID_MAX_DEPTH, MIXED_BID_MAX_LEAVES, parseBidActionClaim, type BidActionClaim } from '@/game/mixed-bid-question';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
import { Input } from './ui/input';
const config: MixedActionConfig<BidActionClaim> = {
prefix: 'mixed-bid',
legend: 'Mixed current-fact / future ordinary-lot bid claim',
actionLabel: 'ordinary-lot bid condition',
actionTiming: 'Future bid · this ordinary lot',
actionHelp: <>Count one future accepted native bid declaration after the answer and before this ordinary lot closes.
Smaller bids do not add together. A qualifying declaration counts even if another player wins; this does not promise a purchase.
If you win, the original payment rules still apply. Existing funding, Karama and prepaid ally aid rules still apply.
Ordinary bank auctions only; optional modules and native special auctions are excluded.
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-lot bid condition.',
maxLeaves: MIXED_BID_MAX_LEAVES,
maxDepth: MIXED_BID_MAX_DEPTH,
newAction: () => ({ minimum: 1 }),
parseAction: value => parseBidActionClaim(value),
renderAction: ({ value, id, disabled, onChange }) => <>
<label htmlFor={`${id}-minimum`}>Minimum bid in spice</label>
<Input id={`${id}-minimum`} type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required disabled={disabled}
value={Number.isFinite(value.minimum) ? value.minimum : ''}
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
</>,
};
export function mixedBidInputError(value: unknown, game: GameView, target: string): string | null {
return mixedActionInputError(value, game, target, config);
}
export function MixedBidFields(props: {
value: MixedActionExpression<BidActionClaim>;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: MixedActionExpression<BidActionClaim>) => void;
}) {
const lot = props.game.bidPromiseLot;
return MixedActionFields({ ...props, config: lot
? { ...config, legend: `${config.legend} · Turn ${lot.turn} · Ordinary lot ${lot.lot}` }
: config });
}
+41
View File
@@ -0,0 +1,41 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { truthQuestionText } from '@/game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { BidPromiseGuideButton } from './bid-promises';
/** Display the original question and whole answer only. Frozen facts, reduced
* thresholds and physical lot sources never belong in this panel. */
export function MixedBidPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) {
const game = props.game, own = game.mixedBidPromises.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 lot = game.bidPromiseLot;
const live = !!lot && lot.turn === game.turn && own.some(promise => promise.turn === lot.turn && promise.lot === lot.lot &&
!promise.fulfilled && !promise.released);
const sharedGuideElsewhere = !!lot && lot.turn === game.turn && game.bidPromises.some(promise => promise.player === game.me &&
promise.turn === lot.turn && promise.lot === lot.lot && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance mixed bid promises" id="mixed-bid-promise-guidance">
<h3>Your mixed bid promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}, ordinary lot {promise.lot}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedBid', target: promise.player,
turn: promise.turn, lot: promise.lot, mixed: promise.mixed }, leaderName,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}
{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 lot.'}
</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 threshold remain private.
AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.</p>
<p className="fine">A Yes answer that still requires a future bid is fulfilled immediately by one qualifying native declaration,
even if another player wins. Smaller bids do not add together. A binding No remains in force until this ordinary lot actually closes.
If you win, the original payment rules still apply. Existing funding, Karama and prepaid ally aid rules still apply.
Ordinary bank auctions only; optional modules and native special auctions are excluded.
{' '}<a href="/rules#truthtrance-mixed-bid">Mixed bid rules</a>.</p>
{live && game.auction?.active !== game.me && <p>Your next bid opportunity is still ahead; this promise remains visible while others bid.</p>}
{live && !sharedGuideElsewhere && <BidPromiseGuideButton {...props} />}
</section>;
}
+26 -2
View File
@@ -2,6 +2,8 @@
import { MixedBribeFields, mixedBribeInputError } from './mixed-bribe-fields';
import { MixedRevivalFields, mixedRevivalInputError } from './mixed-revival-fields';
import { MixedBidFields, mixedBidInputError } from './mixed-bid-fields';
import type { BidActionClaim } from '@/game/mixed-bid-question';
import type { RevivalActionClaim } from '@/game/mixed-revival-question';
import type { BribePaymentClaim } from '@/game/mixed-bribe-question';
import { MixedMovementFields, mixedMovementInputError } from './mixed-movement-fields';
@@ -77,7 +79,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' | 'mixedRevival'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -130,6 +132,11 @@ export function Truthtrance({
{ kind: 'action', action: { kind: 'forces', counter: 'total', minimum: 1 } },
] });
const mixedRevivalError = mixedRevivalInputError(mixedRevival, g, target);
const [mixedBid, setMixedBid] = useState<MixedActionExpression<BidActionClaim>>({ kind: 'and', terms: [
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 10 } },
{ kind: 'action', action: { minimum: 4 } },
] });
const mixedBidError = mixedBidInputError(mixedBid, g, target);
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({
kind: 'and',
@@ -177,6 +184,7 @@ export function Truthtrance({
: kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression }
: kind === 'bribe' ? { kind, target, ...bribeClaim }
: kind === 'revival' ? { kind, target, ...revivalClaim }
: kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid }
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'shipment'
? {
@@ -285,6 +293,13 @@ export function Truthtrance({
</div>
))}
</>
) : w.question!.kind === 'mixedBid' ? (
<>
<p className="notice">Answer the whole mixed question. Current facts freeze now; any remaining bid conditions concern future accepted declarations in this ordinary lot. No keeps the whole expression false. Passing, funding, Karama and the original winning payment still follow the auction rules. An unknown fact requires a commitment that guarantees the whole answer; otherwise choose I don’t know.</p>
{(g.truthMixedBidAnswers ?? []).map(answer => <div key={answer}>
{button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
</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>
@@ -490,6 +505,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="mixedBid">Combine current facts and ordinary bids</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="shipment">
Bind a shipment from reserves
@@ -505,7 +521,14 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'mixedRevival' ? (
{kind === 'mixedBid' ? (
<>
<MixedBidFields value={mixedBid} game={g} target={target}
disabled={busy || !g.truthMixedBidTargets.includes(target)} onChange={setMixedBid} />
{!g.truthMixedBidTargets.includes(target) && <p className="notice">Choose an eligible participant with a future bid opportunity in this quiet ordinary lot.</p>}
<p className="notice" aria-label="Public mixed bid question preview">{mixedBidError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'mixedRevival' ? (
<>
<MixedRevivalFields value={mixedRevival} game={g} target={target}
disabled={busy || !g.truthMixedRevivalTargets.includes(target)} onChange={setMixedRevival} />
@@ -705,6 +728,7 @@ export function Truthtrance({
(kind === 'bribeExpression' && (!!bribeExpressionError || !validBribeExpressionRecipients || !g.truthBribeExpressionTargets.includes(target))) ||
(kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) ||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
(kind === 'mixedShipment' &&