Combine current battle decisions with turn commitments

This commit is contained in:
Quintine committed 2026-10-26 23:11:48 +11:00
1 parent 1a79fd3131
commit f76c8dfb93
15 files changed
+1380 -66

No files matched your search

+23 -26
View File
@@ -261,7 +261,7 @@ import {
territory,
splitLocation,
} from '@/game/board';
import type { Action, GameView } from '@/game/engine';
import type { Action, GameView, Plan } from '@/game/engine';
import { PLAYER_CIRCLE_SECTORS } from '@/game/player-positions';
import { SeatAutopilot } from './seat-autopilot';
import {
@@ -570,6 +570,26 @@ export function GameTable({
});
const [aidAmount, setAidAmount] = useState(0);
const [allyPayment, setAllyPayment] = useState('');
const fillBattlePlan = (plan: Plan) => {
setDial(plan.dial);
setSupport(plan.support);
setBankerSpice(plan.bankerSpice ?? 0);
setAllyPayment(plan.allyPayment === undefined ? '' : String(plan.allyPayment));
setLeader(plan.leader ?? '');
setWeapon(plan.weapon ?? '');
setDefense(plan.defense ?? '');
setPlanetologistDraft({
key: `${g.battle?.event ?? ''}:${plan.leader ?? ''}:${plan.weapon ?? ''}`,
enabled: plan.planetologistSpecial === true,
});
setKwisatz(!!plan.kwisatz);
if (plan.reinforcementsSources) {
const chosen = JSON.stringify(plan.reinforcementsSources);
const index = reinforcementsSourceChoices?.findIndex(choice => JSON.stringify(choice) === chosen);
if (index !== undefined && index >= 0)
setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index });
}
};
const [combineSectors, setCombineSectors] = useState(false);
const [moveNoField, setMoveNoField] = useState(false);
const [moveForces, setMoveForces] = useState<Record<string, number>>({});
@@ -2067,7 +2087,7 @@ export function GameTable({
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={busy} act={act} fillPlan={fillBattlePlan} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
@@ -5311,30 +5331,7 @@ export function GameTable({
act={act}
game={g}
busy={busy}
fill={(plan) => {
setDial(plan.dial);
setSupport(plan.support);
setBankerSpice(plan.bankerSpice ?? 0);
setAllyPayment(
plan.allyPayment === undefined
? ''
: String(plan.allyPayment),
);
setLeader(plan.leader ?? '');
setWeapon(plan.weapon ?? '');
setDefense(plan.defense ?? '');
setPlanetologistDraft({
key: `${g.battle?.event ?? ''}:${plan.leader ?? ''}:${plan.weapon ?? ''}`,
enabled: plan.planetologistSpecial === true,
});
setKwisatz(!!plan.kwisatz);
if (plan.reinforcementsSources) {
const chosen = JSON.stringify(plan.reinforcementsSources);
const index = reinforcementsSourceChoices?.findIndex(choice => JSON.stringify(choice) === chosen);
if (index !== undefined && index >= 0)
setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index });
}
}}
fill={fillBattlePlan}
/>
)}
{g.battle.preLeader && !g.battle.preLeader.closed ? (
+2 -2
View File
@@ -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 Nexus’s alliances with this turn’s bids, Revival, payments, shipment and ground movements</option>
<option value="turnActions">Combine the current battle with this turn’s alliances, 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 alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunity this turn.
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with a supported current battle, Traitor declaration, alliance, 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>
</>
+60 -7
View File
@@ -3,7 +3,9 @@
import { TERRITORIES } from '@/game/board';
import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises';
import { BattleClaimFields } from './battle-promises';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
import { Input } from './ui/input';
@@ -25,11 +27,20 @@ const partners = (game: GameView, target: string) => game.players.some(player =>
const currentNexus = (game: GameView, target: string) => game.status === 'playing' && game.phase === 1 && game.nexus && !game.spiceWindow &&
game.alliancePromiseNexus?.turn === game.turn && game.truthMixedAllianceTargets.includes(target) &&
!game.response && !game.decision && !game.phaseOpening && !game.automaticContinuationPending;
const currentBattle = (game: GameView, target: string, kind: 'battlePlan' | 'traitorCall') => {
const battle = game.battle;
if (game.status !== 'playing' || game.phase !== 6 || !battle || !battle.event || game.response || game.decision ||
game.phaseOpening || game.automaticContinuationPending) return null;
if (kind === 'battlePlan') return game.truthMixedBattleTargets.includes(target) &&
[battle.attacker, battle.defender].includes(target) ? battle : null;
return battle.revealed && game.truthMixedTraitorTargets.includes(target) && battle.traitorVoters.includes(target) &&
!battle.traitorSubmitted.includes(target) ? battle : null;
};
const config: MixedActionConfig<TurnActionLeaf> = {
prefix: 'turn-action',
legend: 'Future alliances, bids, Revival, payments, shipment and ground movements, with optional current facts',
actionLabel: 'alliance, bid, Revival, payment, shipment or ground-movement condition',
actionTiming: 'Future alliance, ordinary bid, Revival, payment, reserve shipment or ground move · this turn',
legend: 'Current battle and this-turn action conditions, with optional current facts',
actionLabel: 'battle, alliance, bid, Revival, payment, shipment or ground-movement condition',
actionTiming: 'Current battle or future native action · 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.
@@ -41,13 +52,17 @@ const config: MixedActionConfig<TurnActionLeaf> = {
Alliance conditions concern actual final membership when this current Nexus closes. Offers alone are not alliances.
A remaining alliance condition settles at the actual close; forming an alliance does not fulfill it early.
No can allow temporary membership followed by a break before the close. You cannot promise another player&apos;s consent.
Battle-plan conditions concern one actual native plan in this current battle, including a plan already sealed or revealed.
An open plan commitment settles when both plans are revealed, not merely when your own plan is sealed.
Traitor conditions concern one original Reveal or decline declaration in the current revealed battle.
The declaration counts before a later Karama or betrayal response can prevent its effect.
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 alliance, ordinary bid, Revival, payment, reserve shipment or ground-movement condition, with optional current facts.',
fallbackError: 'Choose a current battle or future native action condition, with optional current facts.',
maxLeaves: TURN_ACTION_MAX_LEAVES,
maxDepth: TURN_ACTION_MAX_DEPTH,
requireFact: false,
@@ -62,6 +77,12 @@ const config: MixedActionConfig<TurnActionLeaf> = {
throw new Error('Choose a public native leader for the respondent’s faction.');
if (action.kind === 'alliance' && (!currentNexus(game, target) || !partners(game, target).some(partner => partner.id === action.partner)))
throw new Error('Choose a public other partner during the current open Nexus.');
if (action.kind === 'battlePlan' || action.kind === 'traitorCall') {
const battle = currentBattle(game, target, action.kind);
if (!battle || action.battle !== battle.event || action.territory !== battle.territory)
throw new Error('Choose this player’s current native battle or unsubmitted Traitor declaration.');
if (action.kind === 'battlePlan') parseBattleActionClaim(action.claim, game.allLeaders.map(leader => leader.id));
}
return action;
},
renderAction: ({ value, id, game, target, disabled, onChange }) => {
@@ -71,7 +92,10 @@ const config: MixedActionConfig<TurnActionLeaf> = {
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === value.kind) return;
const minimum = value.kind === 'leader' || value.kind === 'alliance' ? 1 : value.minimum;
const minimum = 'minimum' in value ? value.minimum : 1;
if (kind === 'battlePlan') onChange({ kind, battle: game.battle?.event ?? '', territory: game.battle?.territory ?? '',
claim: { kind: 'dial', compare: 'eq', value: 0 } });
if (kind === 'traitorCall') onChange({ kind, battle: game.battle?.event ?? '', territory: game.battle?.territory ?? '', call: true });
if (kind === 'alliance') onChange({ kind, partner: '' });
if (kind === 'payment') onChange({ kind, recipient: '', minimum });
if (kind === 'bid') onChange({ kind, lot: lot?.lot ?? NaN, minimum });
@@ -80,6 +104,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
if (kind === 'ship' || kind === 'move') onChange({ kind,
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
}}>
<option value="battlePlan">Use an actual current battle-plan choice</option>
<option value="traitorCall">Make the current Traitor declaration</option>
<option value="alliance">End this Nexus allied with a named partner</option>
<option value="bid">Place an ordinary-lot bid</option>
<option value="forces">Revive ordinary physical forces</option>
@@ -88,7 +114,34 @@ const config: MixedActionConfig<TurnActionLeaf> = {
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
</select>
{value.kind === 'alliance' ? <>
{value.kind === 'battlePlan' || value.kind === 'traitorCall' ? <>
<label htmlFor={`${id}-battle`}>Current battle</label>
<select id={`${id}-battle`} value={game.battle?.event === value.battle && game.battle.territory === value.territory ? value.battle ?? '' : ''}
disabled={disabled} aria-invalid={!currentBattle(game, target, value.kind) || game.battle?.event !== value.battle || game.battle?.territory !== value.territory}
onChange={event => {
const selected = event.currentTarget.value;
if (selected === '') onChange({ ...value, battle: '', territory: '' });
else if (game.battle && selected === game.battle.event)
onChange({ ...value, battle: game.battle.event, territory: game.battle.territory });
}}>
<option value="">Choose the current battle</option>
{game.battle?.event && <option value={game.battle.event}>{game.combatLocations.find(location => location.id === game.battle?.territory)?.name ?? game.battle.territory}</option>}
</select>
{value.kind === 'battlePlan' ? <fieldset disabled={disabled} data-turn-action-plan-fields={id}>
<BattleClaimFields game={game} value={value.claim} onChange={claim => {
if (!disabled && !('terms' in claim)) onChange({ ...value, claim });
}} />
</fieldset> : <>
<label htmlFor={`${id}-call`}>Original Traitor declaration</label>
<select id={`${id}-call`} value={value.call ? 'reveal' : 'decline'} disabled={disabled}
aria-invalid={typeof value.call !== 'boolean'} onChange={event => {
if (event.currentTarget.value === 'reveal') onChange({ ...value, call: true });
if (event.currentTarget.value === 'decline') onChange({ ...value, call: false });
}}>
<option value="reveal">Reveal traitor</option><option value="decline">Decline traitor</option>
</select>
</>}
</> : value.kind === 'alliance' ? <>
<label htmlFor={`${id}-partner`}>Final Nexus alliance partner</label>
<select id={`${id}-partner`} value={partnerChoices.some(partner => partner.id === value.partner) ? value.partner : ''}
disabled={disabled} aria-invalid={!partnerChoices.some(partner => partner.id === value.partner)} onChange={event => {
@@ -151,7 +204,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
</>}
{value.kind !== 'leader' && value.kind !== 'alliance' && <>
{'minimum' in value && <>
<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'
+32 -7
View File
@@ -1,6 +1,6 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import type { Action, GameView, Plan } from '@/game/engine';
import { bribeAction } from '@/game/bribe-options';
import { alliancePromiseModeSupported } from '@/game/alliance-promises';
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
@@ -9,16 +9,23 @@ import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { Button } from './ui/button';
type TurnActionProps = { game: GameView; busy: boolean; act: (action: Action) => void };
type TurnActionProps = { game: GameView; busy: boolean; act: (action: Action) => void; fillPlan?: (plan: Plan) => void };
const publicAllianceContext = (game: GameView) => ({
players: game.players.map(player => ({ id: player.id, ally: player.ally })), offers: game.allianceOffers,
});
const publicBattleContext = (game: GameView) => game.battle && ({
event: game.battle.event, territory: game.battle.territory, attacker: game.battle.attacker, defender: game.battle.defender,
revealed: game.battle.revealed, submitted: game.battle.submitted,
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
});
/** One projected native packet; no client branch selection or completion solver. */
export function TurnActionGuideButton(props: TurnActionProps) {
const game = props.game, guide = game.turnActionGuide;
if (!guide) return null;
const actor = game.me, turn = game.turn, phase = game.phase, event = guide.event, action = guide.action;
const act = props.act, fillPlan = props.fillPlan, battle = game.battle;
const battleSignature = JSON.stringify(publicBattleContext(game));
const signature = JSON.stringify(action), auction = game.auction, decision = game.decision;
const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival;
const auctionSource = (current: GameView) => current.auction && JSON.stringify({
@@ -77,7 +84,14 @@ export function TurnActionGuideButton(props: TurnActionProps) {
try { ownedAlliance = !!quoteNexusAlliance({ ...publicAllianceContext(current), actor, target: action.target }).formed; }
catch { ownedAlliance = false; }
}
const opportunity = payment || paidBox || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
const currentBattle = phase === 6 && !!battle && current.battle === battle &&
JSON.stringify(publicBattleContext(current)) === battleSignature;
const planWindow = !!battle && currentBattle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) && !battle.submitted.includes(actor);
const plan = action.type === 'battlePlan' && planWindow && !!fillPlan && props.fillPlan === fillPlan;
const battlePreparation = action.type === 'card' && planWindow;
const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed &&
battle.traitorVoters.includes(actor) && !battle.traitorSubmitted.includes(actor);
const opportunity = payment || paidBox || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
? !current.ready.includes(actor) && ['charity', 'ready', 'card'].includes(action.type)
: phase === 3
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
@@ -88,7 +102,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type))
: (phase === 7 || phase === 8) &&
action.type === 'ready' && !current.ready.includes(actor));
return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
return !props.busy && props.act === act && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor &&
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity &&
@@ -104,6 +118,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient';
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
const label = action.type === 'bribe' ? `Pay ${amount} spice to ${recipient} while honoring your whole answer`
: action.type === 'battlePlan' ? 'Fill the quoted battle plan for review before sealing'
: action.type === 'traitorCall' ? action.call ? 'Reveal traitor while honoring your whole answer' : 'Decline traitor while honoring your whole answer'
: action.type === 'alliance' ? action.target === null
? game.players.find(player => player.id === actor)?.ally ? 'Break your current alliance while preserving your whole answer'
: 'Withdraw your alliance offer while preserving your whole answer'
@@ -131,7 +147,11 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: action.type === 'revealNoField' ? 'Reveal your No-Field while preserving your whole answer'
: 'Use the offered native card preparation while preserving your whole answer';
return <Button className="game-action min-h-11 whitespace-normal" data-testid="turn-action-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.act(action); }}>{label}</Button>;
disabled={!canAct()} onClick={() => {
if (!canAct()) return;
if (action.type === 'battlePlan') fillPlan?.(action as Action & Plan);
else act(action);
}}>{label}</Button>;
}
/** Own original whole questions and aggregate status only. */
@@ -140,7 +160,7 @@ 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 alliance, bid, Revival, payment, shipment and movement promises</h3>
<h3>Your battle and turn-action 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 === 'kwisatz' ? 'Kwisatz Haderach' : id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
@@ -165,6 +185,9 @@ export function TurnActionPromises(props: TurnActionProps) {
Alliance conditions concern final actual membership at this Nexus close. Offers alone are not alliances.
A remaining alliance condition settles only at the actual close; forming an alliance does not fulfill it early.
No can allow temporary membership followed by a break before the close. Neither answer promises another player’s consent.
Battle conditions describe your actual plan in the named current battle. An open plan condition settles when both plans are revealed;
sealing your own plan does not finish it. A plan already fixed can decide its condition at the answer.
Traitor conditions describe your original Reveal or decline declaration, before any later Karama or betrayal prevention.
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.
@@ -194,13 +217,15 @@ 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 alliance, bid, Revival, payment, shipment and movement question" className="space-y-3">
return <section aria-label="Answer the whole battle and turn-action 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.
Alliance conditions use actual final membership when the current Nexus closes; offers alone do not count.
A remaining alliance condition stays open through temporary membership until that close. Neither answer promises another player’s consent.
Current battle-plan conditions use the actual native plan; an open condition settles when both plans are revealed.
Traitor conditions use the original Reveal or decline declaration, before later Karama or betrayal prevention.
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>