Add bids and ordinary Revival to combined turn commitments

This commit is contained in:
Quintine committed 2026-10-26 21:42:45 +11:00
1 parent 0d72a33ee5
commit 921b4fe1e3
15 files changed
+1255 -65

No files matched your search

+4 -4
View File
@@ -200,7 +200,7 @@ export function Truthtrance({
const [fact, setFact] = useState<TruthFact>({ kind: 'hand', name: 'Shield' });
const factId = useId();
const leaderName = (id: string) =>
id === CHEAP_HERO_TRAITOR
id === 'kwisatz' ? 'Kwisatz Haderach' : id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
const name = (id: string) => g.players.find((p) => p.id === id)!.name;
@@ -581,7 +581,7 @@ export function Truthtrance({
<option value="mixedBid">Combine current facts and ordinary bids</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
<option value="turnActions">Combine this turn’s payments, shipment and ground movements</option>
<option value="turnActions">Combine this turn’s bids, Revival, payments, shipment and ground movements</option>
<option value="travel">Combine this turn’s shipment and ground movements</option>
<option value="shipment">
Bind a shipment from reserves
@@ -602,7 +602,7 @@ export function Truthtrance({
<>
<TurnActionFields value={turnActions} game={g} target={target}
disabled={busy || !g.truthTurnActionTargets.includes(target)} onChange={setTurnActions} />
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with a supported payment, shipment or ground-movement opportunity this turn.
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with a supported ordinary bid, Revival, payment, shipment or ground-movement opportunity this turn.
Ordinary payments may remain available after their Shipment and Movement opportunity ends.</p>}
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
@@ -887,7 +887,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
<p>
{name(r.asker)} → {name(r.question.target)}:{' '}
{truthQuestionText(r.question, (id) =>
id === CHEAP_HERO_TRAITOR
id === 'kwisatz' ? 'Kwisatz Haderach' : id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id),
id => g.combatLocations.find(place => place.id === id)!.name,
+76 -14
View File
@@ -12,20 +12,34 @@ const recipients = (game: GameView, target: string) => {
const player = game.players.find(player => player.id === target);
return player ? game.players.filter(recipient => recipient.id !== target && recipient.id !== player.ally) : [];
};
/** Public faction names only; casualties, custody and native eligibility stay
* on the server. Advanced Atreides may name their Kwisatz Haderach. */
const leaders = (game: GameView, target: string) => {
const faction = game.players.find(player => player.id === target)?.faction;
return [...game.allLeaders.filter(leader => !!faction && leader.faction === faction),
...(game.advanced && faction === 'atreides' ? [{ id: 'kwisatz', name: 'Kwisatz Haderach' }] : [])];
};
const currentLot = (game: GameView) => game.bidPromiseLot?.turn === game.turn ? game.bidPromiseLot : null;
const config: MixedActionConfig<TurnActionLeaf> = {
prefix: 'turn-action',
legend: 'Future payments, shipment and ground movements, with optional current facts',
actionLabel: 'payment, shipment or ground-movement condition',
actionTiming: 'Future ordinary payment, reserve shipment or ground move · this turn',
legend: 'Future bids, Revival, payments, shipment and ground movements, with optional current facts',
actionLabel: 'bid, Revival, payment, shipment or ground-movement condition',
actionTiming: 'Future ordinary bid, Revival, payment, reserve shipment or ground move · this turn',
actionHelp: <>Ordinary payment conditions sum future real bribes to the same recipient after the answer this turn.
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
Payments made during Mentat Pause wait until next turn&apos;s Mentat opening; collection never counts twice.
Ordinary bid conditions use the highest accepted future bid declaration in the named current lot, never the sum of smaller bids.
A qualifying bid counts even if another player wins; it does not promise a purchase. Winning still requires the original payment.
Ordinary Revival conditions count cumulative future normal and elite physical force returns, with each counter counted once,
or an actual future return of a named native leader. Smaller ordinary force returns add together.
Earlier returns, requests alone, Ghola, foreign leaders and special card returns do not count.
Shipment conditions concern the single future original reserve shipment after the answer.
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
Shipment and movement close at your own Movement turn end, while payment conditions last until the actual turn ends.
AND requires every condition; OR keeps legal alternatives. No makes the whole expression false, not every condition separately.
Original costs, timing, cards, preparation and action rights still apply; this claim grants no extra action.</>,
fallbackError: 'Choose a future ordinary payment, reserve shipment or ground-movement condition, with optional current facts.',
fallbackError: 'Choose a future ordinary bid, Revival, payment, reserve shipment or ground-movement condition, with optional current facts.',
maxLeaves: TURN_ACTION_MAX_LEAVES,
maxDepth: TURN_ACTION_MAX_DEPTH,
requireFact: false,
@@ -34,24 +48,67 @@ const config: MixedActionConfig<TurnActionLeaf> = {
const action = parseTurnActionLeaf(input);
if (action.kind === 'payment' && !recipients(game, target).some(recipient => recipient.id === action.recipient))
throw new Error('Choose a public non-allied recipient for the future ordinary payment.');
if (action.kind === 'bid' && currentLot(game)?.lot !== action.lot)
throw new Error('Choose the current public ordinary lot for the future bid.');
if (action.kind === 'leader' && !leaders(game, target).some(leader => leader.id === action.leader))
throw new Error('Choose a public native leader for the respondent’s faction.');
return action;
},
renderAction: ({ value, id, game, target, disabled, onChange }) => {
const choices = recipients(game, target);
const choices = recipients(game, target), leaderChoices = leaders(game, target), lot = currentLot(game);
return <>
<label htmlFor={`${id}-action`}>Future action</label>
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === value.kind) return;
if (kind === 'payment') onChange({ kind, recipient: '', minimum: value.minimum });
const minimum = value.kind === 'leader' ? 1 : value.minimum;
if (kind === 'payment') onChange({ kind, recipient: '', minimum });
if (kind === 'bid') onChange({ kind, lot: lot?.lot ?? NaN, minimum });
if (kind === 'forces') onChange({ kind, counter: 'total', minimum });
if (kind === 'leader') onChange({ kind, leader: leaderChoices[0]?.id ?? '' });
if (kind === 'ship' || kind === 'move') onChange({ kind,
territory: value.kind === 'payment' ? 'carthag' : value.territory, minimum: value.minimum });
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
}}>
<option value="bid">Place an ordinary-lot bid</option>
<option value="forces">Revive ordinary physical forces</option>
<option value="leader">Revive a named ordinary leader</option>
<option value="payment">Make an ordinary payment</option>
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
</select>
{value.kind === 'payment' ? <>
{value.kind === 'bid' ? <>
<label htmlFor={`${id}-lot`}>Current ordinary lot</label>
<select id={`${id}-lot`} value={lot?.lot === value.lot ? String(value.lot) : ''} disabled={disabled}
aria-invalid={lot?.lot !== value.lot} onChange={event => {
const selected = event.currentTarget.value;
if (selected === '' || lot && selected === String(lot.lot))
onChange({ ...value, lot: selected === '' ? NaN : Number(selected) });
}}>
<option value="">Choose the current ordinary lot</option>
{lot && <option value={lot.lot}>Turn {lot.turn} · Ordinary lot {lot.lot}</option>}
</select>
{!lot && <p className="fine">No public ordinary lot is currently available. Special auctions do not count.</p>}
</> : value.kind === 'forces' ? <>
<label htmlFor={`${id}-counter`}>Physical force counters</label>
<select id={`${id}-counter`} value={value.counter} disabled={disabled} onChange={event => {
const counter = event.currentTarget.value;
if (counter === 'total' || counter === 'normal' || counter === 'elite') onChange({ ...value, counter });
}}>
<option value="total">All physical forces</option>
<option value="normal">Normal forces</option>
<option value="elite">Elite forces</option>
</select>
</> : value.kind === 'leader' ? <>
<label htmlFor={`${id}-leader`}>Named ordinary leader</label>
<select id={`${id}-leader`} value={leaderChoices.some(leader => leader.id === value.leader) ? value.leader : ''}
disabled={disabled} aria-invalid={!leaderChoices.some(leader => leader.id === value.leader)} onChange={event => {
const leader = event.currentTarget.value;
if (leader === '' || leaderChoices.some(choice => choice.id === leader)) onChange({ ...value, leader });
}}>
<option value="">Choose a named leader</option>
{leaderChoices.map(leader => <option key={leader.id} value={leader.id}>{leader.name}</option>)}
</select>
</> : value.kind === 'payment' ? <>
<label htmlFor={`${id}-recipient`}>Ordinary payment recipient</label>
<select id={`${id}-recipient`} value={choices.some(recipient => recipient.id === value.recipient) ? value.recipient : ''}
disabled={disabled} aria-invalid={!choices.some(recipient => recipient.id === value.recipient)}
@@ -71,12 +128,17 @@ const config: MixedActionConfig<TurnActionLeaf> = {
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
</>}
<label htmlFor={`${id}-minimum`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
: `Minimum physical forces in one ${value.kind === 'ship' ? 'reserve shipment' : 'ground move'}`}</label>
<Input id={`${id}-minimum`} type="number" min={1} max={value.kind === 'payment' ? Number.MAX_SAFE_INTEGER : 20}
step={1} required disabled={disabled} value={Number.isFinite(value.minimum) ? value.minimum : ''}
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 || value.kind !== 'payment' && value.minimum > 20}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
{value.kind !== 'leader' && <>
<label htmlFor={`${id}-minimum`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
: value.kind === 'bid' ? 'Minimum future accepted bid in spice'
: value.kind === 'forces' ? 'Minimum future ordinary force returns'
: `Minimum physical forces in one ${value.kind === 'ship' ? 'reserve shipment' : 'ground move'}`}</label>
<Input id={`${id}-minimum`} type="number" min={1} max={value.kind === 'payment' || value.kind === 'bid' ? Number.MAX_SAFE_INTEGER : 20}
step={1} required disabled={disabled} value={Number.isFinite(value.minimum) ? value.minimum : ''}
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 ||
value.kind !== 'payment' && value.kind !== 'bid' && value.minimum > 20}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
</>}
</>;
},
};
+13 -4
View File
@@ -18,7 +18,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival;
const auctionSource = (current: GameView) => current.auction && JSON.stringify({
bid: current.auction.bid, bidder: current.auction.bidder, active: current.auction.active,
remaining: current.auction.remaining, allyPayment: current.auction.allyPayment,
remaining: current.auction.remaining, allyPayment: current.auction.allyPayment, lot: current.bidPromiseLot,
});
const nativeSource = (current: GameView) => current.richeseAuction && JSON.stringify({
event: current.richeseAuction.event, method: current.richeseAuction.method,
@@ -119,10 +119,10 @@ export function TurnActionPromises(props: TurnActionProps) {
if (!own.length) return null;
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance turn-action promises" id="turn-action-promise-guidance">
<h3>Your payment, shipment and movement promises</h3>
<h3>Your bid, Revival, payment, shipment and movement promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'turnActions', target: promise.player,
turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
turn: promise.turn, mixed: promise.mixed }, id => id === 'kwisatz' ? 'Kwisatz Haderach' : id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
: game.allLeaders.find(leader => leader.id === id)?.name ?? id,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}.
@@ -136,8 +136,14 @@ export function TurnActionPromises(props: TurnActionProps) {
<p className="fine">Count the sum of future real ordinary payments to each named recipient after the answer.
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
Payments made during Mentat Pause wait until next turn&apos;s Mentat opening; collection never counts twice.
Bid conditions count the highest accepted future declaration in the named current ordinary lot, never the sum of smaller bids.
A qualifying bid counts even when another player wins; winning still requires the original payment.
Force Revival conditions count cumulative future ordinary returns; normal and elite physical counters each count once.
Named leader conditions require an actual future ordinary native return. Earlier returns, requests alone, Ghola,
foreign leaders and special card returns do not count.
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
never the sum of smaller moves. Earlier shipment and movement do not count.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
Shipment and movement close at your own Movement turn end; ordinary payments remain possible until the actual turn ends.
Original costs, timing, cards, preparation and action rights still apply. This claim grants no extra action.</p>
{live && <p>{game.phase === 8 ? 'Complete any remaining ordinary payment before the actual turn ends. Late recipients collect next turn.'
@@ -164,9 +170,12 @@ export function TurnActionAnswers(props: TurnActionProps) {
!!me && !me.autopilot && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return <section aria-label="Answer the whole payment, shipment and movement question" className="space-y-3">
return <section aria-label="Answer the whole bid, Revival, payment, shipment and movement question" className="space-y-3">
<p className="notice">Answer the whole expression. Current facts freeze now; OR keeps legal alternatives and No keeps the whole expression false.
Future real ordinary payments add together per recipient. Only one future original reserve shipment and actual future ground-movement groups count.
Ordinary bids use the maximum future accepted declaration in the named current lot; ordinary force returns add together,
while named leaders must actually return through ordinary native Revival.
Bids close with their lot; Revival closes with your native Revival opportunity.
Shipment and movement close at your own Movement turn end; payment conditions last until the actual turn ends.
Original rights, preparation, timing and costs still apply. Only your whole answer is shared.</p>
{answers.map(answer => {