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>
+34
View File
@@ -16,6 +16,40 @@ open mode gates.
## Current checkpoint and work
**Truthtrance / combined current battle decisions — 26 October. Prototyped / Partial.**
Whole turn-action questions now combine atomic current battle-plan predicates
or the original revealed-battle Traitor declaration with payments and frozen
facts. Native legality, physical cards, costs and preparation remain. Future
plan receipts settle at both-plan reveal before cleanup; an already sealed own
plan fixes its value at answer. A prevented Reveal retains its original
choice. Missing events are neither a zero plan nor a Decline. Human guidance
fills the existing complete plan for review before separate sealing.
The frozen batch passes **20 files,249/249**, types/lint/build and **four native
paths**,22 GameTable SSR renders/four actual answer callbacks/six guide callbacks/
one plan-fill callback. Original source24 adds9 for frozen fact, pay2 and the
actual original projectile plan; source24 adds11 for a false plan receipt,
cleanup and late Mentat payment. Source26 adds11 for whole No, actual Reveal,
a real payment cap and turn closure. Remote allied Harkonnen source72 adds10
for pay2, Reveal and physical Karama prevention. Original inventories remain.
Thirty-seven helper,24 control and13 new native cases cover signed alternatives,
exact sources, fixed own plans, original declarations, cleanup, late payments
and four minimal profiles. Bounded review fixes later native plan/Traitor answer
preflight against a new-only whole ledger and waits for pending native income.
A genuine two-Truth regression preserves a paid earlier No through the second
question and actual battle/turn closure. V1 passes runtime then finds type
errors; narrow type feedback repairs them. V2 passes all frozen checks.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-combat-proof-v2/`;
journeys/HTML: adjacent `20261026-truth-turn-combat-runtime-v2/`.
Wider preparations, action/module combinations and final rules/browser/deployed/
recovery/privacy/custody acceptance remain. These are local native/SSR/callback
journeys, not deployed acceptance. No database/save/server/reset/restart/
deployment operation is performed; no full mode is certified.
[Contract and source](TRUTHTRANCE_TURN_ACTIONS.md#combined-current-battle-decisions--26-october-2026).
**Truthtrance / combined final Nexus membership — 26 October. Prototyped / Partial.**
The whole turn-action question now includes final original-Nexus membership
alongside payments and later native preparations/transport. Temporary offers,
+36
View File
@@ -1,5 +1,41 @@
# Dune implementation status
## 26 October 2026 — Combined current battle decisions
**Truthtrance / combined current battle decisions — 26 October. Prototyped / Partial.**
Whole turn-action questions now combine atomic current battle-plan predicates
or the original revealed-battle Traitor declaration with payments and frozen
facts. Native legality, physical cards, costs and preparation remain. Future
plan receipts settle at both-plan reveal before cleanup; an already sealed own
plan fixes its value at answer. A prevented Reveal retains its original
choice. Missing events are neither a zero plan nor a Decline. Human guidance
fills the existing complete plan for review before separate sealing.
The frozen batch passes **20 files,249/249**, types/lint/build and **four native
paths**,22 GameTable SSR renders/four actual answer callbacks/six guide callbacks/
one plan-fill callback. Original source24 adds9 for frozen fact, pay2 and the
actual original projectile plan; source24 adds11 for a false plan receipt,
cleanup and late Mentat payment. Source26 adds11 for whole No, actual Reveal,
a real payment cap and turn closure. Remote allied Harkonnen source72 adds10
for pay2, Reveal and physical Karama prevention. Original inventories remain.
Thirty-seven helper,24 control and13 new native cases cover signed alternatives,
exact sources, fixed own plans, original declarations, cleanup, late payments
and four minimal profiles. Bounded review fixes later native plan/Traitor answer
preflight against a new-only whole ledger and waits for pending native income.
A genuine two-Truth regression preserves a paid earlier No through the second
question and actual battle/turn closure. V1 passes runtime then finds type
errors; narrow type feedback repairs them. V2 passes all frozen checks.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-combat-proof-v2/`;
journeys/HTML: adjacent `20261026-truth-turn-combat-runtime-v2/`.
Wider preparations, action/module combinations and final rules/browser/deployed/
recovery/privacy/custody acceptance remain. These are local native/SSR/callback
journeys, not deployed acceptance. No database/save/server/reset/restart/
deployment operation is performed; no full mode is certified.
[Contract and source](TRUTHTRANCE_TURN_ACTIONS.md#combined-current-battle-decisions--26-october-2026).
## 26 October 2026 — Combined final Nexus membership
**Truthtrance / combined final Nexus membership — 26 October. Prototyped / Partial.**
+60 -2
View File
@@ -7,8 +7,8 @@
`{kind:'payment',recipient,minimum}` or native
`{kind:'ship'|'move',territory,minimum}`. Optional current facts and full AND/OR
share sixteen logical leaves/four grouping levels. Old travel/payment question
shapes and mode gates remain. Current ordinary-lot bids and personal native
Revival leaves extend this envelope as detailed below.
shapes and mode gates remain. Current ordinary-lot bids, personal native Revival, final Nexus membership
and current battle decisions extend this envelope as detailed below.
## Authority and meaning
@@ -237,3 +237,61 @@ preparation/order/module combinations, unknown-fact correlations and complete
rules/browser/deployed/recovery/privacy/custody acceptance remain. All existing
preview gates and deferred full AI refinement/calibration remain. This batch
performs no database/save/server/reset/restart/deployment operation.
## Combined current battle decisions — 26 October 2026
**Prototyped / Partial.** Add atomic
`{kind:'battlePlan',battle,territory,claim}` predicates and
`{kind:'traitorCall',battle,territory,call}` to the shared whole question.
The exact current original battle and combat location bind every raw clause,
including folded ones. Plan clauses use the existing native atomic claim
parser and public printed identities; declaration clauses require an original
current revealed-battle unsubmitted voter. No extra eligibility is granted.
Optional private combat metadata holds source, evaluated original plan
predicates and actual declaration context. Future plan receipts are captured
at native reveal before cleanup; an already submitted own plan can be fixed
at answer as in the existing native question. Original declaration counts
before later Karama/prevention; a later prevented effect is not a new choice.
Closure without the original event records absence, not dialzero or Decline.
Whole alternatives and later ordinary payments retain their original meaning.
Native virtual plan/decision constraints quote original legality, preparations,
resources and physical custody. They never become real selected branch rows.
Human plan guidance fills the existing complete form for review rather than
sealing automatically; four minimal profiles can submit the native packet.
Older records remain valid without combat metadata. Public native Traitor
choices and later pure/mixed plan or Traitor questions retain a completion of
earlier whole commitments even when there is no separate bribe ledger. Pending
native income must finish before a plan or preparation guide is offered.
The frozen batch passes **20 files,249/249**, types/lint/build and four actual
native paths:22 GameTable SSR renders, four actual answer callbacks, six guide
callbacks and one human plan-fill callback followed by separate native sealing.
Source24 +9 performs frozen-spice fact, pay2 and the original physical projectile
plan. Source24 +11 fixes a false plan receipt before cleanup and completes its
payment alternative in late Mentat. Source26 +11 declares Reveal under a whole
No, honors the cumulative payment cap and closes the actual turn. Original
remote allied Harkonnen source72 +10 pays2, declares Reveal and receives actual
physical Karama prevention; the declaration receipt stays true. Original
physical inventories remain. No played source state is installed.
Thirty-seven helper and24 control cases support13 new native cases, including
four profiles. A genuine H/A/E two-Truth continuation earns an A/E battle from
source27 by native movement, paid shipment and preparation. After A pays2 under
a whole No, the second native dial question offers only the compatible No;
both actual plans and turn closure fulfill the earlier answer. Bounded review
repairs the new-only ledger preflight and pending-income guide fence. V1's
runtime passes before type errors; narrow type feedback repairs them. V2 passes
runtime, quick checks, the affected union and build on one frozen source.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-combat-proof-v2/`;
journeys/HTML: adjacent `20261026-truth-turn-combat-runtime-v2/`.
These are local engine/SSR/callback journeys, not browser, hydration or deployed
acceptance. Wider preparation/order/action/module combinations, unknown-fact
correlations and final rules/recovery/privacy/custody acceptance remain. Full
AI strategy and calibration stay deferred. Native mode/module preview fences
remain; this batch performs no database/save/server/reset/restart/deployment
operation and does not certify any complete mode.
+165 -8
View File
@@ -1,7 +1,8 @@
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
closeTurnActionRevival, settleTurnActionAlliance, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
@@ -600,6 +601,7 @@ import {
} from './charity';
import {
PlanClaimError,
matchesPlanClaim,
respectsBattlePromises,
type PlanClaim,
type BattlePromise,
@@ -15358,7 +15360,9 @@ function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?
}
function bribeCompatibleLaterPromises(g: Game, p: Player,
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'movementExpressionPromises' | 'battle'>): boolean {
return !hasBribePromises(g, p.id, true) || !!bribePromiseCompletion({ ...g, ...later }, p);
return !hasBribePromises(g, p.id, true) &&
!liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length ||
!!bribePromiseCompletion({ ...g, ...later }, p);
}
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
@@ -18711,6 +18715,78 @@ function turnActionTargetAvailable(g: Game, _actor: string): boolean {
function turnActionTransportOpen(g: Game, actor: string): boolean {
return g.phase < 5 || g.phase === 5 && !!g.movementRemaining?.includes(actor);
}
/** Read only the original predicates while the native sealed cards are still
* held. Receipts contain booleans, never copies of private hands or plans. */
function turnActionPlanValues(g: Game, actor: string, expressions: readonly TurnActionClaim['mixed'][]): TurnActionPlanValue[] {
const battle = g.battle, plan = battle?.plans[actor];
requireRule(battle?.event && plan, 'Retain the actual original submitted battle plan.');
const owner = getPlayer(g, actor), sealed = { ...plan, kwisatz: !!plan.kwisatz };
const values = new Map<string, TurnActionPlanValue>();
const visit = (node: TurnActionClaim['mixed']): void => {
if (node.kind === 'action') {
const action = node.action;
if (action.kind !== 'battlePlan' || action.battle !== battle.event || action.territory !== battle.territory) return;
const value = matchesPlanClaim(action.claim, sealed, owner.hand);
requireRule(typeof value === 'boolean', 'Evaluate every original predicate against the actual native plan.');
values.set(JSON.stringify(action.claim), { claim: structuredClone(action.claim), value });
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
expressions.forEach(visit);
return [...values.values()];
}
function recordTurnActionRevealedPlans(g: Game) {
const battle = g.battle;
if (!g.turnActionPromises || !battle?.event || !battle.revealed) return;
for (const actor of [battle.attacker, battle.defender]) {
const records = liveTurnActionPromises(g.turnActionPromises, actor, g.turn).filter(promise => {
const combat = promise.combat;
return combat && combat.event === battle.event && combat.territory === battle.territory &&
combat.plan && !combat.plan.closed;
});
if (!records.length || !battle.plans[actor]) continue;
const values = turnActionPlanValues(g, actor, records.map(promise => promise.mixed));
g.turnActionPromises = turnActionRule(() => recordTurnActionPlan(g.turnActionPromises!, actor,
g.turn, battle.event!, battle.territory, values));
}
}
/** A disappeared original opportunity is absence, never a synthetic zero plan
* or Decline. Declaration effects can later change; only the native hook
* records a declaration receipt. */
function reconcileTurnActionCombat(g: Game) {
if (!g.turnActionPromises) return;
recordTurnActionRevealedPlans(g);
g.turnActionPromises = g.turnActionPromises.map(promise => {
const combat = promise.combat;
if (!combat || promise.fulfilled || promise.released) return promise;
const current = g.status === 'playing' && g.turn === promise.turn && g.phase === 6 &&
g.battle?.event === combat.event && g.battle.territory === combat.territory;
const context = current && combat.traitor && !combat.traitor.closed
? traitorTruthContext(g, promise.player) : null;
const lostTraitor = combat.traitor && !combat.traitor.closed && (!context ||
context.submitted !== undefined || context.beneficiary !== combat.traitor.beneficiary ||
context.target !== combat.traitor.target || context.leader !== combat.traitor.leader);
return !current || lostTraitor
? turnActionRule(() => closeTurnActionCombat([promise], promise.turn, combat.event))[0] : promise;
});
}
function turnActionTraitorChoices(g: Game, actor: string, choices: boolean[]): boolean[] {
if (!liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn).some(promise =>
promise.combat?.traitor && !promise.combat.traitor.closed)) return choices;
const context = traitorTruthContext(g, actor), battle = g.battle;
if (!context || context.submitted !== undefined || !battle?.event) return choices;
return choices.filter(call => {
try {
const promises = turnActionRule(() => recordTurnActionTraitor(g.turnActionPromises!, actor,
g.turn, battle.event!, battle.territory, call));
const trial = { ...g, turnActionPromises: promises, battle: { ...battle,
traitorCalls: { ...battle.traitorCalls, [actor]: call },
traitorDecisionPromises: traitorDecisionRule(() =>
settleTraitorDecisionPromises(context, battle.traitorDecisionPromises ?? [], call)),
} };
return !!turnActionCompletion(trial, actor);
} catch (error) { if (error instanceof RuleError) return false; throw error; }
});
}
function initializeTurnActionOrder(g: Game) {
initializeTravelOrder(g);
const payments = [
@@ -18799,7 +18875,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
return !!turnActionCompletion(source, player.id);
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[] };
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action };
/** Whole-answer alternatives stay private. Only detached native rows quote a
* route, sharing actual interval progress and the remaining physical purse. */
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
@@ -18826,6 +18902,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
trial.bribePromises ??= [];
trial.shipmentPromises ??= [];
trial.movementPromises ??= [];
let invalidCombat = false, requestedTraitor = false;
leaves.forEach((leaf, i) => {
const metadata = { event: `turn-action-witness:${i}`, player: actor, turn: source.turn,
asker: source.players.find(player => player.id !== actor)!.id, answer: leaf.answer };
@@ -18842,7 +18919,27 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
else if (leaf.kind === 'leader') (trial.leaderRevivalPromises ??= []).push({ ...metadata, leader: leaf.leader });
else if (leaf.kind === 'alliance') (trial.alliancePromises ??= []).push({ ...metadata,
source: leaf.source, partner: leaf.partner });
else if (leaf.kind === 'battlePlan') {
const battle = trial.battle;
if (!battle || battle.event !== leaf.battle || battle.territory !== leaf.territory ||
![battle.attacker, battle.defender].includes(actor)) invalidCombat = true;
else (battle.truthPromises ??= []).push({ event: metadata.event, player: actor,
asker: metadata.asker, claim: leaf.claim, answer: leaf.answer });
} else if (leaf.kind === 'traitorCall') {
const context = traitorTruthContext(trial, actor);
if (!context || context.submitted !== undefined || context.battle !== leaf.battle ||
trial.battle?.territory !== leaf.territory || context.beneficiary !== leaf.beneficiary ||
context.target !== leaf.target || context.leader !== leaf.leader) invalidCombat = true;
else {
requestedTraitor = true;
(trial.battle!.traitorDecisionPromises ??= []).push({ event: metadata.event,
turn: trial.turn, battle: context.battle, player: actor, asker: metadata.asker,
beneficiary: context.beneficiary, target: context.target, leader: context.leader,
answer: leaf.answer ? leaf.call : !leaf.call });
}
}
});
if (invalidCombat) return null;
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent);
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
@@ -18882,6 +18979,21 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
for (const payment of required)
checkBureaucratContributors(funded, payment.recipient, [{ payer: actor, amount: payment.amount }]);
reconcileBattlePromises(funded, voluntary);
const battle = funded.battle;
const needsPlan = !!battle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) &&
!!battle.truthPromises?.some(promise => promise.player === actor && !promise.released);
const battlePlan = needsPlan ? findReachableBattlePlan(funded, payer) : undefined;
if (needsPlan && !battlePlan) continue;
const traitorContext = traitorTruthContext(funded, actor);
const needsTraitor = requestedTraitor || !!battle?.traitorDecisionPromises?.some(promise =>
promise.player === actor && !promise.fulfilled && !promise.released);
let traitor: Action | undefined;
if (needsTraitor) {
if (!traitorContext || traitorContext.submitted !== undefined) continue;
const choices = traitorDecisionRule(() => traitorDecisionChoices(traitorContext, battle?.traitorDecisionPromises ?? []));
if (!choices.length) continue;
traitor = { type: 'traitorCall', call: choices[0] };
}
reconcileBidPromises(funded, voluntary);
reconcileRevivalPromises(funded, voluntary);
const lot = bidOpportunity(funded);
@@ -18904,6 +19016,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
if (completion) {
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
return { ...completion, ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
}
@@ -18919,11 +19032,13 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
const open = turnActionTransportOpen(g, p.id);
let bid = false, revival = false, alliance = false;
let bid = false, revival = false, alliance = false, plan = false, traitor = false;
const visit = (node: TurnActionClaim['mixed']): void => {
if (node.kind === 'action') {
if (node.action.kind === 'bid') bid = true;
if (node.action.kind === 'alliance') alliance = true;
if (node.action.kind === 'battlePlan') plan = true;
if (node.action.kind === 'traitorCall') traitor = true;
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
@@ -18937,12 +19052,24 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
revival: revival ? { total: 0, normal: 0, elite: 0, leaders: [], closed: g.phase > 4 ||
g.phase === 4 && !!g.revivalOrder && !g.revivalOrder.remaining.includes(p.id) } : null,
} : undefined;
return { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
const battle = plan || traitor ? g.battle : null;
requireRule(!plan && !traitor || !!battle?.event, 'Retain the original current battle.');
const context = traitor ? traitorTruthContext(g, p.id) : null;
requireRule(!traitor || context && context.submitted === undefined, 'Retain the original unsubmitted Traitor decision.');
const combat: TurnActionCombat | undefined = battle?.event ? { event: battle.event, territory: battle.territory,
plan: plan ? { closed: false, values: null } : null,
traitor: context ? { beneficiary: context.beneficiary, target: context.target, leader: context.leader,
closed: false, call: null } : null } : undefined;
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
...(combat ? { combat } : {}),
...(preparation ? { preparation } : {}),
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
shipment: { pending: open && (g.phase < 5 || shipmentAvailable(g, p)), event: null },
progress: [], payments: [], transportClosed: !open, paymentClosed: false,
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
if (combat?.plan && battle?.plans[p.id]) promise = turnActionRule(() => recordTurnActionPlan([promise], p.id,
g.turn, combat.event, combat.territory, turnActionPlanValues(g, p.id, [claim.mixed])))[0];
return promise;
}
function turnActionAnswerSource(g: Game, player: string, alliance = false): Game {
return g.phase === 1 && alliance || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
@@ -18953,7 +19080,9 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru
if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return [];
const answers: TruthAnswer[] = [], consent = { value: false };
for (const answer of [true, false]) {
const promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id);
let promise: TurnActionPromise;
try { promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id); }
catch (error) { if (error instanceof RuleError) continue; throw error; }
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance),
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
@@ -18973,6 +19102,7 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
appendTravelOrder(g, 'turnActions', g.turnActionPromises.length - 1);
}
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
reconcileTurnActionCombat(g);
turnActionRule(() => validateTurnActionPromises(g.turnActionPromises!, g.players.map(player => player.id)));
for (const p of g.players) {
for (const turn of new Set(g.turnActionPromises!.filter(promise => promise.player === p.id &&
@@ -19083,6 +19213,15 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
} else if (!action && g.phase === 5 && g.active === player.id && !promise.transportClosed)
action = completion.actions[0] ?? { type: 'endMovement' };
}
if (!action && g.phase === 6 && g.battle) {
const battle = g.battle;
if (completion.battlePlan && !completion.battlePlan.waitingForIncome && !battle.revealed && !battle.plans[player.id] &&
(!battle.preLeader || battle.preLeader.closed) && !battle.preparation &&
(!battle.fullPlan || battle.plans[battle.fullPlan.target] || battle.fullPlan.target === player.id))
action = completion.battlePlan.actions[0] ?? { type: 'battlePlan', ...completion.battlePlan.plan };
if (!action && completion.traitor && battle.revealed && battle.traitorCalls[player.id] === undefined)
action = completion.traitor;
}
if (!action && g.phase === 1 && allianceQuestionWindow(g) && !g.ready.includes(player.id))
action = completion.alliance?.[0] ?? { type: 'ready' };
if (!action && [7, 8].includes(g.phase) && !g.ready.includes(player.id)) action = { type: 'ready' };
@@ -28854,7 +28993,8 @@ function traitorDecisionRule<T>(operation: () => T): T {
}
function traitorTruthAnswers(g: Game, p: Player): TruthAnswer[] {
const context = traitorTruthContext(g, p.id);
return context ? traitorDecisionRule(() => traitorDecisionChoices(context, g.battle?.traitorDecisionPromises ?? []))
return context ? turnActionTraitorChoices(g, p.id,
traitorDecisionRule(() => traitorDecisionChoices(context, g.battle?.traitorDecisionPromises ?? [])))
.map(call => call ? 'yes' as const : 'no' as const) : [];
}
function bindTraitorTruth(g: Game, p: Player, answer: boolean, asker: string, event?: string) {
@@ -28950,6 +29090,12 @@ function reconcileTraitorTruth(g: Game, actor?: string, action?: Action) {
reconcileMixedTraitorQuestions(g);
}
function traitorReturnPromiseBlock(g: Game, p: Player, returned: readonly string[]): string | null {
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).some(promise =>
promise.combat?.traitor && !promise.combat.traitor.closed)) {
const trial = structuredClone(g);
getPlayer(trial, p.id).traitors = p.traitors.filter(card => !returned.includes(card));
if (!turnActionCompletion(trial, p.id)) return 'Keep the identity needed by your whole Traitor declaration answer, or retain another legal alternative.';
}
const promises = g.battle?.traitorDecisionPromises;
if (!promises?.some(promise => promise.player === p.id && promise.answer && !promise.fulfilled && !promise.released)) return null;
const context = traitorTruthContext(g, p.id, p.traitors.filter(card => !returned.includes(card)));
@@ -40992,6 +41138,7 @@ function applyActionInner(
revealPlayerNoField(g, owner, 'battle');
}
b.revealed = true;
recordTurnActionRevealedPlans(g);
reconcileMixedBattleQuestions(g);
for (const plan of Object.values(b.plans)) {
const l = g.players
@@ -41029,6 +41176,15 @@ function applyActionInner(
}
if (action.call && b.traitorDeclarationVersion)
b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id));
if (g.turnActionPromises && b.event) {
requireRule(!liveTurnActionPromises(g.turnActionPromises, id, g.turn).some(promise => {
const combat = promise.combat, stage = combat?.traitor;
return combat && combat.event === b.event && combat.territory === b.territory && stage && !stage.closed &&
(!context || stage.beneficiary !== context.beneficiary || stage.target !== context.target || stage.leader !== context.leader);
}), 'Retain the original Traitor beneficiary, target and opposing leader.');
g.turnActionPromises = turnActionRule(() => recordTurnActionTraitor(g.turnActionPromises!, id,
g.turn, b.event!, b.territory, action.call as boolean));
}
b.traitorCalls[id] = action.call;
if (action.call && g.moritaniAssassinate?.owner === id) {
requireRule(b.event && !g.moritaniAssassinateCallEvents!.includes(b.event), 'This normal traitor call was already recorded.');
@@ -42642,7 +42798,8 @@ export function viewGame(state: Game, id: string) {
: {},
traitorSubmitted: Object.keys(b.traitorCalls),
traitorCallChoices: (() => { const context = traitorTruthContext(g, id); return context
? traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? [])) : null; })(),
? turnActionTraitorChoices(g, id,
traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? []))) : null; })(),
ownTraitorPromises: (b.traitorDecisionPromises ?? []).filter(promise => promise.player === id && !isMixedTraitorEvent(g, promise.event)),
traitorVoters: traitorVoters(g, b),
preparation: b.preparation && g.homeworldOccupationPreview && b.preparation.kind === 'voice'
+7 -5
View File
@@ -3189,9 +3189,9 @@ export const RULE_TOPICS: RuleTopic[] = [
id: 'truthtrance-turn-actions',
title: 'Truthtrance: combined alliances, bids, Revival, payments and transport',
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine final current-Nexus membership, ordinary current-lot bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
summary: 'Combine current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'Choose Combine this turn’s alliances, bids, Revival, payments and transport. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of the native current-lot bid, ordinary Revival, funded-payment and owned-stage travel prototypes; wider action families and optional combinations remain unfinished.',
'Choose Combine the current battle with this turn’s alliances, bids, Revival, payments, shipment and ground movements. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of native battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.',
@@ -3199,6 +3199,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'An ordinary-bid clause names the current public lot and counts the maximum new accepted declaration after answering. Earlier bids, summed bids and later purchase invoices do not count. A pass can reset after another bid, so No stays binding through reentry until that original physical lot closes.',
'Ordinary force Revival clauses accumulate future total, normal or elite physical returns. Personal native leader clauses include eligible Advanced Atreides Kwisatz. Prior returns, Ghola, foreign leaders and special returns do not count. Original costs, slots, cycles and actual return controls remain.',
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
'A battle-plan condition names an atomic part of your actual plan in this current battle. An open condition settles when both original plans reveal; a plan already sealed fixes its value at the answer. The human guide fills your existing plan for review before sealing. Native leaders, physical cards, Voice, costs and ordered preparation still apply.',
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
'Transport closes at your actual Movement end. A payment alternative may remain after that close, through the full current turn including Mentat. Complete a required payment before finishing the turn. No payment caps remain until their native opportunity closes.',
@@ -3206,11 +3208,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native final-alliance, bid, Revival, payment and transport constraints with separate actual intervals and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources; broader composition and final acceptance remain.', evidence: ['tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources; broader composition and final acceptance remain.', evidence: ['tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts'] },
],
},
{
+20
View File
@@ -384,6 +384,24 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
const action = parseTurnActionLeaf(input);
if (action.kind === 'battlePlan' || action.kind === 'traitorCall') {
const battle = g.battle;
check(g.phase === 6 && battle && typeof battle.event === 'string' && battle.event.length > 0 &&
action.battle === battle.event && action.territory === battle.territory,
'Choose this original current battle event and territory.');
if (action.kind === 'battlePlan') {
check([battle.attacker, battle.defender].includes(respondent.id), 'Ask an actual combatant about their own current native plan.');
action.claim = parseBattleActionClaim(action.claim, battleLeaderIdentities(g).map(leader => leader.id));
} else {
const voters = [battle.attacker, battle.defender];
if (battle.ecazOccupy?.lead) for (const id of [battle.ecazOccupy.ecaz, battle.ecazOccupy.ally])
if (!voters.includes(id)) voters.push(id);
const harkonnen = g.players.find(player => player.faction === 'harkonnen');
if (harkonnen?.ally && voters.includes(harkonnen.ally) && !voters.includes(harkonnen.id)) voters.push(harkonnen.id);
check(battle.revealed && voters.includes(respondent.id) && battle.traitorCalls[respondent.id] === undefined,
'Choose an actual current revealed, unsubmitted native Traitor voter.');
}
}
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
if (action.kind === 'bid') check(bidPromiseModeSupported(g) && g.phase === 3 && g.auction &&
@@ -713,6 +731,8 @@ export function truthQuestionText(
action => {
if (action.kind === 'payment') return `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}`;
if (action.kind === 'bid') return `you ${bidClaimText({ turn: q.turn, ...action })}`;
if (action.kind === 'battlePlan') return `in your own native plan in this original battle in ${locationName(action.territory)}, ${planClaimText(action.claim, leaderName)}`;
if (action.kind === 'traitorCall') return `you actually declare ${action.call ? 'Reveal' : 'Decline'} in this original battle in ${locationName(action.territory)} (later prevention does not change the declaration)`;
if (action.kind === 'alliance') return `you will ${allianceClaimText({ turn: q.turn, partner: action.partner }, playerName)} (final membership; temporary offers or pairs do not settle this clause)`;
if (action.kind === 'forces' || action.kind === 'leader') return `you ${revivalExpressionText(action,
id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your ordinary Revival opportunity ends this turn`;
+174 -8
View File
@@ -4,6 +4,8 @@ import { BidPromiseError, parseBidClaim } from './bid-promises';
import { AlliancePromiseError, parseAllianceClaim } from './alliance-promises';
import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-revival-question';
import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises';
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
import { PlanClaimError } from './battle-promises';
import {
MixedActionQuestionError,
parseMixedActionExpression,
@@ -23,7 +25,9 @@ export const TURN_ACTION_MAX_LEAVES = 16;
export const TURN_ACTION_MAX_DEPTH = 4;
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string };
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } |
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean };
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
export type TurnActionPayment = { recipient: string; amount: number };
export type TurnActionBranchLeaf = TravelBranchLeaf |
@@ -31,13 +35,23 @@ export type TurnActionBranchLeaf = TravelBranchLeaf |
{ kind: 'bid'; lot: number; source: string; minimum: number; answer: boolean } |
{ kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number; progress: number; answer: boolean } |
{ kind: 'leader'; leader: string; answer: boolean } |
{ kind: 'alliance'; partner: string; source: string; answer: boolean };
{ kind: 'alliance'; partner: string; source: string; answer: boolean } |
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim; answer: boolean } |
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean; answer: boolean;
beneficiary: string; target: string; leader: string | null };
export type TurnActionPreparation = {
bid: { lot: number; source: string; maximum: number; closed: boolean } | null;
revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null;
};
export type TurnActionBid = { lot: number; source: string; amount: number };
export type TurnActionAlliance = { source: string; partner: string | null; closed: boolean };
export type TurnActionPlanValue = { claim: MixedBattleActionClaim; value: boolean };
export type TurnActionCombat = {
event: string;
territory: string;
plan: { closed: boolean; values: TurnActionPlanValue[] | null } | null;
traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null;
};
export type TurnActionPromise = TurnActionClaim & {
event: string;
player: string;
@@ -58,6 +72,7 @@ export type TurnActionPromise = TurnActionClaim & {
preparation?: TurnActionPreparation;
/** Final membership is recorded only by the original Nexus closing event. */
alliance?: TurnActionAlliance;
combat?: TurnActionCombat;
};
export class TurnActionError extends Error {
@@ -84,10 +99,27 @@ const physical = (value: unknown): value is number => whole(value) && value <= 2
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** Identity membership is decoded by Truthtrance; here retain one strict atom. */
function parseCombatClaim(value: unknown): MixedBattleActionClaim {
try {
return parseBattleActionClaim(value, object(value) && value.kind === 'leader' && identifier(value.leader)
? [value.leader] : []);
} catch (error) { if (error instanceof PlanClaimError) throw new TurnActionError(error.message); throw error; }
}
const planKey = (claim: MixedBattleActionClaim): string => JSON.stringify(parseCombatClaim(claim));
/** Public leaves carry only their native destination/recipient and minimum. */
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
check(object(value), 'Choose one future ordinary bid, Revival, final Nexus alliance, reserve shipment, ground movement or bribe payment.');
try {
if (value.kind === 'battlePlan' || value.kind === 'traitorCall') {
check(keys(value, value.kind === 'battlePlan' ? 'battle,claim,kind,territory' : 'battle,call,kind,territory') &&
identifier(value.battle) && identifier(value.territory), 'Retain the exact original current battle and native location.');
if (value.kind === 'battlePlan') return { kind: 'battlePlan', battle: value.battle,
territory: value.territory, claim: parseCombatClaim(value.claim) };
check(typeof value.call === 'boolean', 'Choose the actual Reveal or Decline declaration.');
return { kind: 'traitorCall', battle: value.battle, territory: value.territory, call: value.call };
}
if (value.kind === 'alliance') {
check(keys(value, 'kind,partner'), 'Supply only one final Nexus alliance partner.');
const claim = parseAllianceClaim({ turn: 1, partner: value.partner });
@@ -126,6 +158,8 @@ function storedFact(value: unknown, depth = 0): TruthFact {
}
function actionKey(action: TurnActionLeaf): string {
if (action.kind === 'battlePlan') return JSON.stringify([action.kind, action.battle, action.territory, planKey(action.claim)]);
if (action.kind === 'traitorCall') return JSON.stringify([action.kind, action.battle, action.territory, action.call]);
if (action.kind === 'alliance') return JSON.stringify([action.kind, action.partner]);
if (action.kind === 'leader') return JSON.stringify([action.kind, action.leader]);
if (action.kind === 'forces') return JSON.stringify([action.kind, action.counter, action.minimum]);
@@ -170,6 +204,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
const fields = 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn'.split(',');
if ('preparation' in promise) fields.push('preparation');
if ('alliance' in promise) fields.push('alliance');
if ('combat' in promise) fields.push('combat');
check(keys(promise, fields.sort().join(',')) &&
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
'Retain the complete original turn-action commitment and its metadata.');
@@ -188,6 +223,9 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
const lots = new Set<number>();
let hasRevival = false;
let hasAlliance = false;
const combatSources = new Set<string>();
const planClaims = new Set<string>();
let hasTraitor = false;
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
if (node.kind === 'action') {
originals.add(actionKey(node.action));
@@ -196,12 +234,47 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
if (node.action.kind === 'bid') lots.add(node.action.lot);
if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = true;
if (node.action.kind === 'alliance') hasAlliance = true;
if (node.action.kind === 'battlePlan' || node.action.kind === 'traitorCall')
combatSources.add(JSON.stringify([node.action.battle, node.action.territory]));
if (node.action.kind === 'battlePlan') planClaims.add(planKey(node.action.claim));
if (node.action.kind === 'traitorCall') hasTraitor = true;
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
check(!lots.size || !!promise.preparation?.bid, 'Retain the original private lot binding for every ordinary bid leaf.');
check(!hasRevival || !!promise.preparation?.revival, 'Retain this answer’s ordinary Revival interval.');
check(!hasAlliance || !!promise.alliance, 'Retain the original private Nexus binding for every final-alliance leaf.');
check(!planClaims.size || !!promise.combat?.plan, 'Retain the original private plan stage for every battle-plan clause.');
check(!hasTraitor || !!promise.combat?.traitor, 'Retain the original private Traitor declaration stage.');
if ('combat' in promise) {
const combat = promise.combat;
check(object(combat) && keys(combat, 'event,plan,territory,traitor') && identifier(combat.event) &&
identifier(combat.territory) && [...combatSources].every(source => source === JSON.stringify([combat.event, combat.territory])),
'Retain one original current combat event and territory.');
if (combat.plan !== null) {
check(object(combat.plan) && keys(combat.plan, 'closed,values') && typeof combat.plan.closed === 'boolean' &&
(combat.plan.closed || combat.plan.values === null), 'Retain an open plan or an actual closed plan receipt.');
if (combat.plan.values !== null) {
check(Array.isArray(combat.plan.values) && combat.plan.values.length === planClaims.size,
'Retain every distinct original plan predicate exactly once.');
const seen = new Set<string>();
for (const entry of combat.plan.values) {
check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual native plan values.');
const key = planKey(entry.claim);
check(planClaims.has(key) && !seen.has(key), 'Retain only unique original plan predicates in the actual receipt.');
seen.add(key);
}
}
}
if (combat.traitor !== null) {
const traitor = combat.traitor;
check(object(traitor) && keys(traitor, 'beneficiary,call,closed,leader,target') && identifier(traitor.beneficiary) &&
identifier(traitor.target) && traitor.beneficiary !== traitor.target &&
(traitor.leader === null || identifier(traitor.leader)) && typeof traitor.closed === 'boolean' &&
(traitor.call === null || typeof traitor.call === 'boolean') && (traitor.closed || traitor.call === null),
'Retain the public original Traitor context and actual declaration only after it closes.');
}
}
if ('alliance' in promise) {
const alliance = promise.alliance;
check(object(alliance) && keys(alliance, 'closed,partner,source') && identifier(alliance.source) &&
@@ -257,6 +330,10 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
}
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
if (action.kind === 'battlePlan') return promise.combat?.plan?.values?.find(entry =>
planKey(entry.claim) === planKey(action.claim))?.value ?? false;
if (action.kind === 'traitorCall') return !!promise.combat?.traitor?.closed &&
promise.combat.traitor.call !== null && promise.combat.traitor.call === action.call;
if (action.kind === 'alliance') return !!promise.alliance?.closed && promise.alliance.partner === action.partner;
if (action.kind === 'bid') return (promise.preparation?.bid?.maximum ?? 0) >= action.minimum;
if (action.kind === 'forces') return (promise.preparation?.revival?.[action.counter] ?? 0) >= action.minimum;
@@ -276,6 +353,8 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
}
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
if (action.kind === 'battlePlan') return promise.combat!.plan!.closed;
if (action.kind === 'traitorCall') return promise.combat!.traitor!.closed;
if (action.kind === 'alliance') return promise.alliance!.closed;
if (action.kind === 'bid') return promise.preparation!.bid!.closed;
if (action.kind === 'forces' || action.kind === 'leader') return promise.preparation!.revival!.closed;
@@ -315,6 +394,11 @@ function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): bo
if (left.kind !== right.kind) return false;
if (left.kind === 'leader') return right.kind === 'leader' && left.leader === right.leader;
if (left.kind === 'alliance') return right.kind === 'alliance' && left.partner === right.partner && left.source === right.source;
if (left.kind === 'battlePlan') return right.kind === 'battlePlan' && left.battle === right.battle &&
left.territory === right.territory && planKey(left.claim) === planKey(right.claim);
if (left.kind === 'traitorCall') return right.kind === 'traitorCall' && left.battle === right.battle &&
left.territory === right.territory && left.call === right.call && left.beneficiary === right.beneficiary &&
left.target === right.target && left.leader === right.leader;
if (left.kind === 'forces') return right.kind === 'forces' && left.counter === right.counter && left.progress === right.progress;
if (left.kind === 'bid') return right.kind === 'bid' && left.lot === right.lot && left.source === right.source;
if (left.kind === 'payment') return right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress;
@@ -325,7 +409,14 @@ function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): bo
function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf[]): TurnActionBranchLeaf[] | null {
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
if (leaf.kind === 'leader' || leaf.kind === 'alliance') {
if (leaf.kind === 'traitorCall') {
const prior = merged.find(other => other.kind === 'traitorCall' && other.battle === leaf.battle &&
other.territory === leaf.territory && other.beneficiary === leaf.beneficiary && other.target === leaf.target && other.leader === leaf.leader);
if (prior?.kind === 'traitorCall' && (prior.answer ? prior.call : !prior.call) !== (leaf.answer ? leaf.call : !leaf.call)) return null;
if (!prior) merged.push({ ...leaf });
continue;
}
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan') {
const prior = merged.find(other => sameAction(other, leaf));
if (prior && prior.answer !== leaf.answer) return null;
if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' &&
@@ -334,12 +425,12 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
continue;
}
const same = merged.find(other => sameAction(other, leaf) && other.answer === leaf.answer);
if (same && same.kind !== 'leader' && same.kind !== 'alliance')
if (same && 'minimum' in same)
same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
else merged.push({ ...leaf });
const yes = merged.find(other => sameAction(other, leaf) && other.answer);
const no = merged.find(other => sameAction(other, leaf) && !other.answer);
if (yes && no && yes.kind !== 'leader' && no.kind !== 'leader' && yes.kind !== 'alliance' && no.kind !== 'alliance' &&
if (yes && no && 'minimum' in yes && 'minimum' in no &&
yes.minimum >= no.minimum) return null;
if (leaf.kind === 'ship' && leaf.answer && merged.some(other =>
other.kind === 'ship' && other.answer && other.territory !== leaf.territory)) return null;
@@ -358,7 +449,10 @@ export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnA
const achieved = actionValue(action, promise);
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
if (action.kind === 'bid') yield [{ ...action, source: promise.preparation!.bid!.source, answer }];
else if (action.kind === 'alliance') yield [{ ...action, source: promise.alliance!.source, answer }];
else if (action.kind === 'traitorCall') {
const { beneficiary, target, leader } = promise.combat!.traitor!;
yield [{ ...action, beneficiary, target, leader, answer }];
} else if (action.kind === 'alliance') yield [{ ...action, source: promise.alliance!.source, answer }];
else if (action.kind === 'forces') yield [{ ...action, progress: promise.preparation!.revival![action.counter], answer }];
else if (action.kind === 'payment') yield [{ ...action, answer,
progress: promise.payments.find(payment => payment.recipient === action.recipient)?.amount ?? 0 }];
@@ -391,6 +485,73 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
? { ...update, fulfilled: true } : update;
}
/** A union receipt may serve several answers; each stored row retains exactly
* its original distinct atomic predicates and no unrelated native plan values. */
export function recordTurnActionPlan<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: string, territory: string, values: readonly TurnActionPlanValue[]): T[] {
check(identifier(event) && identifier(territory) && Array.isArray(values), 'Retain the actual original battle-plan event and predicate values.');
const receipt = new Map<string, TurnActionPlanValue>();
for (const entry of values) {
check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual plan predicate values.');
const claim = parseCombatClaim(entry.claim), key = planKey(claim);
check(!receipt.has(key), 'Retain each actual plan predicate once.');
receipt.set(key, { claim, value: entry.value });
}
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
const combat = promise.combat;
if (!combat?.plan || combat.plan.closed || combat.event !== event || combat.territory !== territory) continue;
const claims = new Set<string>();
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
if (node.kind === 'action' && node.action.kind === 'battlePlan') claims.add(planKey(node.action.claim));
else if (node.kind !== 'fact' && node.kind !== 'action') node.terms.forEach(visit);
};
visit(promise.mixed);
const actual = [...claims].map(key => {
const value = receipt.get(key);
check(value, 'Retain the actual value of every original plan predicate.');
return { claim: { ...value.claim }, value: value.value };
});
updates.set(promise, settle(promise, { ...promise, combat: { ...combat, plan: { closed: true, values: actual } } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** The actual native declaration is independent of later prevention/effect. */
export function recordTurnActionTraitor<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: string, territory: string, call: boolean): T[] {
check(identifier(event) && identifier(territory) && typeof call === 'boolean',
'Retain the actual original battle and Reveal or Decline declaration.');
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
const combat = promise.combat;
if (!combat?.traitor || combat.traitor.closed || combat.event !== event || combat.territory !== territory) continue;
updates.set(promise, settle(promise, { ...promise, combat: { ...combat,
traitor: { ...combat.traitor, closed: true, call } } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Closing without the original native event records absence, never a zero
* dial or Decline. Existing closed receipts and terminal rows stay untouched. */
export function closeTurnActionCombat<T extends TurnActionPromise>(promises: readonly T[], turn: number,
event: string): T[] {
check(Array.isArray(promises) && positive(turn) && identifier(event), 'Choose the original combat opportunity.');
for (const promise of promises) check(object(promise), 'Retain an original turn-action record.');
return promises.map(promise => {
if (promise.turn !== turn || promise.fulfilled || promise.released) return promise;
readPromise(promise);
const combat = promise.combat;
if (!combat || combat.event !== event || (!combat.plan || combat.plan.closed) && (!combat.traitor || combat.traitor.closed)) return promise;
return settle(promise, { ...promise, combat: { ...combat,
plan: combat.plan && !combat.plan.closed ? { closed: true, values: null } : combat.plan,
traitor: combat.traitor && !combat.traitor.closed ? { ...combat.traitor, closed: true, call: null } : combat.traitor,
} });
});
}
/** Only the exact original Nexus closure records final membership. Temporary
* proposals/pairs never call this hook. A newly impossible whole No releases;
* the engine rejects own voluntary closure by inspecting that detached result. */
@@ -569,7 +730,9 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null,
} } : {}) };
const fulfilled = !(promise.alliance && !promise.alliance.closed) && turnActionValue(closed) === promise.answer;
const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed ||
promise.combat.traitor && !promise.combat.traitor.closed);
const fulfilled = !(promise.alliance && !promise.alliance.closed) && !unresolvedCombat && turnActionValue(closed) === promise.answer;
return { ...closed, fulfilled, released: !fulfilled };
});
}
@@ -585,7 +748,8 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
const allClosed = promise.transportClosed && promise.paymentClosed &&
(!promise.preparation?.bid || promise.preparation.bid.closed) &&
(!promise.preparation?.revival || promise.preparation.revival.closed) &&
(!promise.alliance || promise.alliance.closed);
(!promise.alliance || promise.alliance.closed) && (!promise.combat?.plan || promise.combat.plan.closed) &&
(!promise.combat?.traitor || promise.combat.traitor.closed);
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
@@ -593,6 +757,8 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
check(!events.has(promise.event) && players.includes(promise.player) && players.includes(promise.asker) &&
promise.player !== promise.asker && recipientsValid(promise.mixed) &&
(!promise.alliance || promise.alliance.partner === null || players.includes(promise.alliance.partner)) &&
(!promise.combat?.traitor || players.includes(promise.combat.traitor.beneficiary) &&
players.includes(promise.combat.traitor.target) && promise.combat.traitor.target !== promise.player) &&
(promise.released || (promise.answer || !satisfied) &&
(!promise.answer || promise.fulfilled === satisfied) &&
(!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) &&
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { createTruthMixedBattleFixture } from './fixture-truth-mixed-battle';
import { createTruthMixedTraitorFixture, createTruthMixedTraitorHarkonnenFixture } from './fixture-truth-mixed-traitor';
import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision';
import {
actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer, type TruthTurnActionStep,
} from './fixture-truth-turn-action';
import type { TruthTurnAllianceLeaf } from './fixture-truth-turn-alliance';
export type TruthTurnCombatLeaf = TruthTurnAllianceLeaf |
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean };
export type TruthTurnCombatExpression = MixedActionExpression<TruthTurnCombatLeaf>;
export type TruthTurnCombatStep = TruthTurnActionStep;
export const actTruthTurnCombat = actTruthTurnAction;
export const truthTurnCombatPlayer = truthTurnActionPlayer;
export const truthTurnCombatInventory = truthTurnActionInventory;
export const truthTurnCombatTraitors = truthTraitorDecisionTraitors;
export const truthTurnCombatClean = (game: Game) => !game.truthtrance && !game.response && !game.decision &&
!game.phaseOpening && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival;
export const truthTurnCombatFact = (fact: TruthFact): TruthTurnCombatExpression => ({ kind: 'fact', fact });
export const truthTurnCombatPayment = (recipient: string, minimum = 2): TruthTurnCombatExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnCombatPlan = (battle: string, territory: string, claim: MixedBattleActionClaim): TruthTurnCombatExpression =>
({ kind: 'action', action: { kind: 'battlePlan', battle, territory, claim: structuredClone(claim) } });
export const truthTurnCombatCall = (battle: string, territory: string, call = true): TruthTurnCombatExpression =>
({ kind: 'action', action: { kind: 'traitorCall', battle, territory, call } });
export const truthTurnCombatGroup = (kind: 'and' | 'or', ...terms: TruthTurnCombatExpression[]): TruthTurnCombatExpression =>
({ kind, terms });
/** Original Basic24 beforePlans, original Basic26 unsubmitted Traitor voter,
* or original remote allied Harkonnen72. The no-match variant actually seals
* another living original Atreides disc; it never exchanges a Traitor or
* installs plans. Optional-module Cunning stays outside the native envelope. */
export function createTruthTurnCombatFixture(options: {
source?: 'plan' | 'traitor' | 'noMatch' | 'harkonnen'; mixed?: TruthTurnCombatExpression;
} = {}) {
const sourceKind = options.source ?? 'plan';
const planSource = sourceKind === 'plan' ? createTruthMixedBattleFixture() : null;
const traitorSource = sourceKind === 'traitor' || sourceKind === 'noMatch' ? createTruthMixedTraitorFixture() : null;
const harkonnenSource = sourceKind === 'harkonnen' ? createTruthMixedTraitorHarkonnenFixture({ kind: 'ally' }) : null;
const original = planSource ?? traitorSource ?? harkonnenSource!;
let game = structuredClone(original.game), ownPlan = structuredClone(original.ownPlan);
let actions = structuredClone(original.sourceActions);
if (sourceKind === 'noMatch') {
assert.ok(traitorSource);
game = structuredClone(traitorSource.beforePlans);
actions = structuredClone(traitorSource.sourceActions.slice(0, -traitorSource.planSteps.length));
const other = truthTurnCombatPlayer(game, traitorSource.asker).leaders.find(disc =>
!disc.dead && !disc.usedAt && disc.id !== traitorSource.traitorHeld); assert.ok(other);
ownPlan = { ...traitorSource.ownPlan, leader: other.id };
game = actTruthTurnCombat(game, traitorSource.asker, ownPlan, actions);
game = actTruthTurnCombat(game, traitorSource.target, traitorSource.targetPlan, actions);
assert.ok(game.battle?.revealed);
assert.deepEqual(viewGame(game, traitorSource.target).battle!.traitorCallChoices, [false]);
}
assert.ok(game.battle?.event);
const event = game.battle.event, territory = game.battle.territory;
const asker = original.asker, target = original.target, recipient = asker;
const weaponClaim: MixedBattleActionClaim = planSource?.weaponClaim ?? { kind: 'weapon', name: null };
const mixed = structuredClone(options.mixed ?? truthTurnCombatGroup('and', planSource
? truthTurnCombatPlan(event, territory, weaponClaim) : truthTurnCombatCall(event, territory), truthTurnCombatPayment(recipient)));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnCombatPlayer(game, target).spice };
const traitorHeld = planSource?.base.base.traitorHeld ?? traitorSource?.traitorHeld ?? harkonnenSource!.traitorHeld;
assert.ok(truthTurnCombatPlayer(game, asker).hand.some(card => card.id === original.truth));
assert.equal(truthTurnCombatPlayer(game, target).ally === recipient, false);
assert.deepEqual(truthTurnCombatInventory(game), original.originalCards);
assert.deepEqual(truthTurnCombatTraitors(game), original.originalTraitors);
assert.equal(game.turnActionPromises?.length ?? 0, 0);
return {
game, source: structuredClone(game), sourceKind, planSource, traitorSource, harkonnenSource,
asker, target, recipient, truth: original.truth, event, battle: event, territory, mixed, question,
initial: original.initial, setup: original.setup, beforeStartingDeal: original.beforeStartingDeal,
afterSetup: original.afterSetup, originalCards: original.originalCards, originalTraitors: original.originalTraitors,
originalSpice: original.originalSpice, actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: original.sourceActionCount, selected: actions.length,
originalPlanSeals: sourceKind === 'plan' ? 0 : 2 },
weaponClaim, spiceFact, traitorHeld, ownPlan, targetPlan: structuredClone(original.targetPlan),
projectilePlan: planSource ? structuredClone(planSource.projectilePlan) : null,
ordinary: planSource?.ordinary ?? traitorSource?.ordinary ?? null, karama: harkonnenSource?.karama ?? null,
beneficiary: harkonnenSource?.beneficiary ?? target,
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
reveal: { type: 'traitorCall', call: true } satisfies Action,
decline: { type: 'traitorCall', call: false } satisfies Action,
sourceDisclosure: `${original.sourceDisclosure} This adapter changes only the raw combined current-battle question. ` +
(sourceKind === 'noMatch' ? 'The original beforePlans prefix is followed by two actual seals using another living original Atreides disc; no original Traitor card is moved. ' : '') +
'Only actual plan reveal and native Traitor declaration supply future receipts. No hand, cash, physical card, force, death, battle, plan, vote, clock or promise is installed.',
};
}
export type TruthTurnCombatFixture = ReturnType<typeof createTruthTurnCombatFixture>;
export function declareTruthTurnCombat(f: TruthTurnCombatFixture, source = f.game, actions: TruthTurnCombatStep[] = []) {
const before = structuredClone(source), from = actions.length;
let game = actTruthTurnCombat(source, f.asker, { type: 'card', card: f.truth }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
game = actTruthTurnCombat(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, actions, actionCount: actions.length - from };
}
export function askTruthTurnCombat(game: Game, asker: string, target: string, mixed: TruthTurnCombatExpression,
actions: TruthTurnCombatStep[] = []): Game {
return actTruthTurnCombat(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnCombat(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
actions: TruthTurnCombatStep[] = []): Game {
return actTruthTurnCombat(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnCombat(f: TruthTurnCombatFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, actions: TruthTurnCombatStep[] = []) {
const from = actions.length, declared = declareTruthTurnCombat(f, source, actions);
const asked = askTruthTurnCombat(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthTurnCombat(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length - from };
}
export function truthTurnCombatPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
}
export function revealTruthTurnCombat(game: Game, f: TruthTurnCombatFixture, plan: Action = f.projectilePlan!,
actions: TruthTurnCombatStep[] = []) {
assert.ok(f.planSource, 'Only the original unsealed-plan source supplies both actual plan controls');
game = actTruthTurnCombat(game, f.target, plan, actions); const afterTargetPlan = structuredClone(game);
game = actTruthTurnCombat(game, f.asker, f.ownPlan, actions); assert.ok(game.battle?.revealed);
return { game, afterTargetPlan, actions };
}
export function nextTruthTurnCombatNative(game: Game): TruthTurnCombatStep | null {
return nextStrongholdFactionsNativeStep(game);
}
export function nextTruthTurnCombat(game: Game): TruthTurnCombatStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).turnActionGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthTurnCombatNative(game);
}
export function advanceTruthTurnCombat(game: Game, until: (state: Game) => boolean,
actions: TruthTurnCombatStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original combat source ended before its requested native boundary');
const next = nativeOnly ? nextTruthTurnCombatNative(game) : nextTruthTurnCombat(game);
assert.ok(next, 'Supply the actual original private plan or current native vote before continuing');
game = actTruthTurnCombat(game, next.actor, next.action, actions);
}
throw Error('The genuine combat source did not reach its finite boundary');
}
export function settleTruthTurnCombat(game: Game, actions: TruthTurnCombatStep[] = []): Game {
return advanceTruthTurnCombat(game, truthTurnCombatClean, actions);
}
export function finishTruthTurnCombatBattle(game: Game, f: TruthTurnCombatFixture, actions: TruthTurnCombatStep[] = []): Game {
assert.ok(game.battle?.revealed, 'Actually reveal both original plans before cleanup');
if (!f.planSource) assert.notEqual(game.battle.traitorCalls[f.target], undefined, 'Actually declare the bound native Traitor choice');
return advanceTruthTurnCombat(game, state => !state.battle || state.battle.event !== f.event, actions, true);
}
export function finishTruthTurnCombat(game: Game, actions: TruthTurnCombatStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnCombat(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnCombatClean(state), actions);
}
+106 -1
View File
@@ -5,8 +5,10 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { BattleClaimFields } from '../components/battle-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
import { createRevivalOrder } from '../game/revival-order';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import type { TurnActionLeaf } from '../game/turn-action-promises';
@@ -26,6 +28,10 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
const traitorClaim = (call = true): Expression =>
({ kind: 'action', action: { kind: 'traitorCall', battle: 'controlled-native-battle', territory: 'arrakeen', call } });
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
function elements(node: ReactNode): Element[] {
@@ -84,6 +90,17 @@ function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
game.turnActionPromises[0].mixed = { kind: 'or', terms: [finalAlliance(), payment()] };
return game;
}
function combat(revealed = false, next: Action = { type: 'battlePlan', dial: 3, support: 2, bankerSpice: 1, allyPayment: 1,
leader: 'controlled-leader', weapon: 'controlled-weapon', defense: 'controlled-defense', kwisatz: true,
planetologistSpecial: true, reinforcementsSources: { arrakeen: { normal: 1, elite: 0 } } }): GameView {
const game = projected(6, next);
game.battle = { event: 'controlled-native-battle', territory: 'arrakeen', attacker: 'p', defender: 'a', revealed,
submitted: [], traitorVoters: ['p', 'a'], traitorSubmitted: [], plans: {}, truthPromises: [],
} as unknown as NonNullable<GameView['battle']>;
game.truthMixedBattleTargets = ['p', 'a']; game.truthMixedTraitorTargets = revealed ? ['p', 'a'] : [];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [revealed ? traitorClaim() : planClaim(), payment()] };
return game;
}
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
const game = projected(4, next);
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
@@ -116,6 +133,11 @@ function answering(phase = 5): GameView {
game.truthTurnActionAnswers = ['yes', 'no', 'unknown'];
return game;
}
function editPlan(tree: ReactNode, path: string, claim: MixedBattleActionClaim) {
const fields = elements(find(tree, 'data-turn-action-plan-fields', `truth-turn-action-${path}`))
.find(element => element.type === BattleClaimFields); assert.ok(fields);
(fields.props.onChange as (claim: MixedBattleActionClaim) => void)(claim);
}
void test('turn-action editor keeps all action families and optional facts in the original whole expression', () => {
const e = editor(physical('ship', 3)); assert.equal(e.error(), null);
@@ -374,6 +396,89 @@ void test('turn-action Nexus callbacks expire with public consent, membership, r
source.nexus = true; press(retained);
});
void test('turn-action current battle editor reuses atomic plan fields and public declaration sources within the global logical budget', () => {
const game = combat(), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'tanks'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['plans', 'cards', 'ownForces', 'compliantPlan', 'compliantPreparation'])
Object.defineProperty(game.battle!, key, { get() { throw Error(`No private battle ${key} inspection`); }, configurable: true });
select(e.tree(), '1', 'action', 'battlePlan'); editPlan(e.tree(), '1', { kind: 'dial', compare: 'eq', value: 3 });
assert.deepEqual(e.value, planClaim()); assert.equal(e.error(), null);
for (const invalid of [NaN, -1, 0.25, Infinity, 40.5]) {
editPlan(e.tree(), '1', { kind: 'dial', compare: 'eq', value: invalid }); assert.ok(e.error());
editPlan(e.tree(), '1', { kind: 'dial', compare: 'gte', value: 0.5 }); assert.equal(e.error(), null);
}
const foreign = game.allLeaders.find(leader => leader.faction === 'atreides'); assert.ok(foreign);
editPlan(e.tree(), '1', { kind: 'leader', leader: foreign.id }); assert.equal(e.error(), null);
assert.match(e.html(), /Cheap Hero \/ Heroine/);
editPlan(e.tree(), '1', { kind: 'weapon', name: 'Shield' }); assert.equal(e.error(), null);
assert.match(e.html(), /already sealed or revealed/);
game.battle!.event = 'later-native-battle'; assert.ok(e.error()); select(e.tree(), '1', 'battle', 'later-native-battle'); assert.equal(e.error(), null);
game.battle!.revealed = true; assert.equal(e.error(), null, 'A publicly fixed plan remains a valid question source.');
select(e.tree(), '1', 'action', 'traitorCall'); assert.ok(e.error()); game.truthMixedTraitorTargets = ['p', 'a'];
select(e.tree(), '1', 'call', 'decline'); assert.equal(e.error(), null);
game.battle!.traitorSubmitted.push('p'); assert.ok(e.error());
const frozen = editor(planClaim(), true, combat()); editPlan(frozen.tree(), '1', { kind: 'support', compare: 'eq', value: 7 });
select(frozen.tree(), '1', 'battle', ''); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, planClaim());
const fullGame = combat(), full = editor({ kind: 'or', terms: [planClaim(), fact()] }, false, fullGame);
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'battlePlan'); }
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
assert.doesNotMatch(full.html(), /truth-battle-plan-expression/);
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
const deep = editor({ kind: 'and', terms: [fact(), planClaim()] }, false, fullGame);
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
});
void test('turn-action battle-plan guide fills the exact native packet for human review and never seals automatically', () => {
const game = combat(), filled: Plan[] = [], sent: Action[] = [];
for (const key of ['combat', 'compiled', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['plans', 'ownForces', 'compliantPlan', 'compliantPreparation'])
Object.defineProperty(game.battle!, key, { get() { throw Error(`No private battle ${key} inspection`); }, configurable: true });
const props = { game, busy: false, fillPlan(plan: Plan) { filled.push(plan); }, act(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.equal(filled[0], game.turnActionGuide!.action); assert.deepEqual(sent, []);
assert.equal(filled[0].dial, 3); assert.equal(filled[0].support, 2); assert.equal(filled[0].allyPayment, 1);
assert.equal(filled[0].bankerSpice, 1); assert.equal(filled[0].weapon, 'controlled-weapon'); assert.equal(filled[0].defense, 'controlled-defense');
assert.deepEqual(filled[0].reinforcementsSources, { arrakeen: { normal: 1, elite: 0 } });
assert.equal(filled[0].kwisatz, true); assert.equal(filled[0].planetologistSpecial, true);
assert.equal((filled[0] as Plan & Action).turnActionPromiseEvent, event);
assert.match(renderToStaticMarkup(createElement(TurnActionPromises, props)), /Fill the quoted battle plan for review before sealing/);
const noFill = guide({ game, busy: false, act() { assert.fail('A human plan guide cannot auto seal without a fill callback'); } });
assert.equal(noFill.props.disabled, true); press(noFill);
props.fillPlan = () => assert.fail('A replaced fill callback cannot receive a saved plan'); press(button); assert.equal(filled.length, 1);
const preparation = combat(false, { type: 'card', card: 'controlled-ghola', leader: 'controlled-dead-leader' }), prepared: Action[] = [];
press(guide({ game: preparation, busy: false, act(action) { prepared.push(action); } }));
assert.equal(prepared[0], preparation.turnActionGuide!.action);
const declaration = combat(true, { type: 'traitorCall', call: false }), declared: Action[] = [];
declaration.battle!.attacker = 'r'; declaration.battle!.traitorVoters = ['p'];
press(guide({ game: declaration, busy: false, act(action) { declared.push(action); } }));
assert.equal(declared[0], declaration.turnActionGuide!.action, 'A native allied voter retains their own declaration packet.');
});
void test('turn-action combat callbacks retain current battle ownership and raw history without private stage reconstruction', () => {
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
[() => combat(), [game => { game.battle!.submitted.push('p'); }, game => { game.battle!.revealed = true; }, game => { game.battle!.attacker = 'r'; }]],
[() => combat(true, { type: 'traitorCall', call: true }), [game => { game.battle!.traitorSubmitted.push('p'); },
game => { game.battle!.revealed = false; }, game => { game.battle!.traitorVoters = ['a']; }]],
];
for (const [build, specific] of cases) for (const change of [...specific,
(game: GameView) => { game.battle = { ...game.battle! }; }, (game: GameView) => { game.battle!.event = 'later-battle'; },
(game: GameView) => { game.battle!.territory = 'carthag'; }, (game: GameView) => { game.phase = 5; },
(game: GameView) => { game.truthtrance = answering(6).truthtrance; },
]) {
const game = build(), button = guide({ game, busy: false, fillPlan() { assert.fail('Stale battle cannot fill'); }, act() { assert.fail('Stale battle cannot send'); } });
assert.equal(button.props.disabled, false); change(game); press(button);
}
const game = combat(); game.turnActionGuide = null; game.battle = null;
for (const key of ['combat', 'compiled', 'binding', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /sealing your own plan does not finish it/); assert.match(html, /original Reveal or decline declaration/);
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
});
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
const game = projected(); game.turnActionGuide = null;
game.turnActionPromises.push(
+304
View File
@@ -0,0 +1,304 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
import { createTruthTurnActionFixture } from './fixture-truth-turn-action';
import {
actTruthTurnCombat as act, advanceTruthTurnCombat as advance, answerTruthTurnCombat as answer,
askTruthTurnCombat as ask, bindTruthTurnCombat as bind, createTruthTurnCombatFixture as fixture,
declareTruthTurnCombat as declare, finishTruthTurnCombat as finish, finishTruthTurnCombatBattle as cleanup,
nextTruthTurnCombatNative as nativeStep, revealTruthTurnCombat as reveal, settleTruthTurnCombat as settle,
truthTurnCombatCall as call, truthTurnCombatClean as clean, truthTurnCombatFact as fact,
truthTurnCombatGroup as group, truthTurnCombatInventory as inventory, truthTurnCombatPayment as payment,
truthTurnCombatPlan as plan, truthTurnCombatPlayer as player, truthTurnCombatPromise as promise,
truthTurnCombatTraitors as traitors, type TruthTurnCombatFixture, type TruthTurnCombatStep,
} from './fixture-truth-turn-combat';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthTurnCombatFixture) {
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
}
void test('native combat adapters keep the original beforePlans and unsubmitted Traitor sources without installed played state', () => {
for (const source of ['plan', 'traitor', 'noMatch', 'harkonnen'] as const) {
const f = fixture({ source }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActionCount);
assert.equal(f.sourceActionCount, source === 'plan' ? 24 : source === 'harkonnen' ? 72 : 26);
assert.equal(f.originalCards.length, 33); assert.equal(f.game.advanced, false); assert.equal(f.game.nexusCards, undefined);
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
assert.ok(f.game.battle?.event); assert.equal(f.game.battle.event, f.event); assert.equal(f.game.battle.territory, f.territory);
assert.equal(f.game.battle.revealed, source !== 'plan'); assert.deepEqual(f.game.battle.traitorCalls, {});
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
if (source === 'noMatch') {
assert.notEqual(f.game.battle.plans[f.asker].leader, f.traitorHeld);
assert.ok(player(f.game, f.target).traitors.includes(f.traitorHeld));
assert.deepEqual(viewGame(f.game, f.target).battle!.traitorCallChoices, [false]);
}
}
});
void test('native plan AND payment freezes current spice and uses the exact originally held projectile through actual reveal', () => {
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary && f.planSource);
assert.equal(player(f.planSource.beforeShipment, f.target).spice - player(f.game, f.target).spice, 1);
assert.equal(player(f.game, f.target).spice, 9);
const mixed = group('and', fact(f.spiceFact), plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
assert.equal(player(game, f.target).spice, 7); assert.equal(player(game, f.recipient).bribes, 2);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
rejected(game, f.target, f.targetPlan);
const sealed = reveal(game, f, f.projectilePlan); game = sealed.game;
assert.equal(sealed.afterTargetPlan.battle?.revealed, false);
assert.equal(sealed.afterTargetPlan.turnActionPromises![0].combat?.plan?.closed, false);
assert.equal(game.battle!.plans[f.target].weapon, f.ordinary);
assert.equal(player(game, f.target).spice, 7, 'Original Basic zero-dial/zero-support plans add no invented spice charge');
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: true }]);
assert.equal(game.turnActionPromises![0].combat?.plan?.closed, true);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native plan OR payment preserves either manual exact-weapon plan or real payment route', () => {
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary);
const mixed = group('or', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
const bound = bind(f, 'yes', mixed).game;
let armed = reveal(bound, f, f.projectilePlan).game;
assert.equal(promise(armed, f.target).fulfilled, true); assert.equal(player(armed, f.recipient).bribes, 0);
let paid = act(bound, f.target, f.payment); assert.equal(promise(paid, f.target).fulfilled, true);
paid = reveal(paid, f, f.targetPlan).game;
assert.equal(paid.battle!.plans[f.target].weapon, null);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.ordinary));
armed = cleanup(armed, f); paid = cleanup(paid, f);
for (const state of [armed, paid]) { assert.equal(promise(state, f.target).fulfilled, true); physical(state, f); }
});
void test('native whole plan AND payment No retains both alternatives and rejects only completion of the exact predicate', () => {
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary);
const mixed = group('and', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
for (const first of ['payment', 'plan'] as const) {
let game = bind(f, 'no', mixed).game;
if (first === 'payment') {
game = act(game, f.target, f.payment); rejected(game, f.target, f.projectilePlan);
game = reveal(game, f, f.targetPlan).game;
assert.ok(player(game, f.target).hand.some(card => card.id === f.ordinary));
} else {
game = reveal(game, f, f.projectilePlan).game; rejected(game, f.target, f.payment);
game = act(game, f.target, { ...f.payment, amount: 1 });
rejected(game, f.target, { ...f.payment, amount: 1 });
}
assert.ok(!promise(game, f.target).released); game = cleanup(game, f); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native false revealed plan receipt survives original battle cleanup while its payment OR remains due until late Mentat', () => {
const f = fixture(), mixed = group('or', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = reveal(game, f, f.targetPlan).game;
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: false }]);
assert.ok(!promise(game, f.target).fulfilled); game = cleanup(game, f);
assert.equal(game.battle, null); assert.ok(!promise(game, f.target).released);
const receipt = structuredClone(game.turnActionPromises![0].combat);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(game.turnActionPromises![0].combat, receipt);
physical(game, f);
});
void test('native already submitted own plan is a fixed answer-time predicate without resubmitting or revealing a foreign plan', () => {
const f = fixture(); assert.ok(f.projectilePlan);
let game = act(f.game, f.target, f.projectilePlan); assert.equal(game.battle?.revealed, false);
const fixed = structuredClone(game.battle!.plans[f.target]);
game = bind(f, 'yes', f.mixed, game).game;
assert.equal(game.turnActionPromises![0].combat?.plan?.closed, true);
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: true }]);
assert.ok(!promise(game, f.target).fulfilled); assert.equal(game.battle!.plans[f.asker], undefined);
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(game.battle!.plans[f.target], fixed); game = act(game, f.asker, f.ownPlan);
assert.ok(game.battle?.revealed); game = cleanup(game, f); physical(game, f);
});
void test('native matching Reveal and Decline each combine with actual ordinary payment at the original Traitor declaration', () => {
const f = fixture({ source: 'traitor' });
for (const selected of [true, false]) {
const mixed = group('and', call(f.event, f.territory, selected), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [selected]);
rejected(game, f.target, { type: 'traitorCall', call: !selected });
game = act(game, f.target, { type: 'traitorCall', call: selected });
assert.equal(game.turnActionPromises![0].combat?.traitor?.closed, true);
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, selected);
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native whole Reveal AND payment No keeps payment or the actual opposite declaration as independent alternatives', () => {
const f = fixture({ source: 'traitor' }), mixed = group('and', call(f.event, f.territory), payment(f.recipient));
for (const first of ['payment', 'reveal'] as const) {
let game = bind(f, 'no', mixed).game;
if (first === 'payment') {
game = act(game, f.target, f.payment); rejected(game, f.target, f.reveal);
game = act(game, f.target, f.decline);
} else {
game = act(game, f.target, f.reveal); rejected(game, f.target, f.payment);
game = act(game, f.target, { ...f.payment, amount: 1 });
rejected(game, f.target, { ...f.payment, amount: 1 });
}
assert.ok(!promise(game, f.target).released); game = cleanup(game, f); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native genuinely unmatched opposing leader preserves a funded payment OR without inventing a Traitor identity', () => {
const f = fixture({ source: 'noMatch' }), mixed = group('or', call(f.event, f.territory), payment(f.recipient));
let game = bind(f, 'yes', mixed).game;
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false]); rejected(game, f.target, f.reveal);
game = act(game, f.target, f.decline); assert.ok(!promise(game, f.target).fulfilled);
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, false);
game = cleanup(game, f); assert.ok(!promise(game, f.target).released);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native remote allied Harkonnen Reveal fulfills at declaration before its actually allowed or prevented effect', () => {
const f = fixture({ source: 'harkonnen' }); assert.ok(f.karama);
assert.equal(player(f.game, f.target).ally, f.beneficiary);
assert.ok(![f.game.battle!.attacker, f.game.battle!.defender].includes(f.target));
for (const prevented of [false, true]) {
let game = bind(f).game; game = act(game, f.target, f.payment); game = act(game, f.target, f.reveal);
assert.equal(game.response?.kind, 'harkonnenTraitor'); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(game.turnActionPromises![0].combat?.traitor?.beneficiary, f.beneficiary);
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, true);
const receipt = structuredClone(game.turnActionPromises![0].combat);
if (prevented) game = act(game, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
else game = settle(game);
assert.equal(game.battle!.traitorCalls[f.target], !prevented);
assert.deepEqual(game.turnActionPromises![0].combat, receipt); assert.equal(promise(game, f.target).fulfilled, true);
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native current combat leaves and exact guides enforce event territory owner phase and original raw history', () => {
for (const source of ['plan', 'traitor'] as const) {
const f = fixture({ source }), declared = declare(f);
const native = source === 'plan' ? plan(f.event, f.territory, f.weaponClaim) : call(f.event, f.territory);
const stale = source === 'plan' ? plan('stale-combat', f.territory, f.weaponClaim) : call('stale-combat', f.territory);
const wrongTerritory = source === 'plan' ? plan(f.event, 'carthag', f.weaponClaim) : call(f.event, 'carthag');
for (const invalid of [stale, wrongTerritory]) rejected(declared.game, f.asker, { type: 'truthAsk', question: {
...f.question, mixed: group('or', payment(f.recipient), invalid),
} });
rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
let game = answer(ask(declared.game, f.asker, f.target, group('and', native, payment(f.recipient))), f.target, 'yes');
game = act(game, f.target, f.payment);
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, source === 'plan' ? 'battlePlan' : 'traitorCall');
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, 6); rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-guide-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
rejected(game, f.target, wire);
if (source === 'plan') game = act(game, f.asker, f.ownPlan);
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f);
rejected(game, f.target, wire); physical(game, f);
}
});
void test('all four minimal profiles actually answer and use native plan or Traitor guides through combined fulfillment', () => {
for (const source of ['plan', 'traitor'] as const) {
const f = fixture({ source }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnCombatStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 24 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.turnActionGuide;
if (guide) {
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
assert.ok(action, `${source}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else if (source === 'plan' && game.battle?.plans[f.target] && !game.battle.plans[f.asker]) {
game = act(game, f.asker, f.ownPlan, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${source}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${source}/${difficulty}`);
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
assert.ok(actions.some(step => step.actor === f.target && step.action.type === (source === 'plan' ? 'battlePlan' : 'traitorCall')));
game = cleanup(game, f); physical(game, f);
}
}
});
void test('two genuine Truths keep a later native plan candidate compatible with an already paid whole No', () => {
// The inherited original H/A/E prefix really holds both Harkonnen Truths.
// Actual A endMovement, E's paid reserve shipment and native battle controls
// earn this battle. No second card, army, purse, battle or ledger is set.
const f = createTruthTurnActionFixture(), actions = structuredClone(f.sourceActions);
const originalTraitors = traitors(f.game), opponent = f.observer;
let game = act(f.game, f.target, { type: 'endMovement' }, actions);
game = advance(game, state => state.phase === 5 && state.active === opponent && clean(state), actions, true);
const beforeShipment = player(game, opponent).spice;
game = settle(act(game, opponent, { type: 'ship', territory: 'arrakeen', sector: 10,
amount: 1, elite: 0, allyPayment: 0 }, actions), actions);
assert.equal(player(game, opponent).spice, beforeShipment - 1);
game = act(game, opponent, { type: 'endMovement' }, actions);
game = advance(game, state => state.phase === 6 && !!state.active && !state.battle && clean(state), actions, true);
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'arrakeen' &&
[candidate.attacker, candidate.defender].includes(f.target) && [candidate.attacker, candidate.defender].includes(opponent));
assert.ok(choice);
game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions);
game = advance(game, state => !!state.battle && clean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false, actions, true);
assert.ok(game.battle?.event); assert.equal(player(game, f.target).spice, 10);
assert.deepEqual(player(game, f.asker).hand.map(card => card.id), f.truths);
assert.ok(actions.length > f.sourceActionCount); assert.deepEqual(inventory(game), f.originalCards);
const event = game.battle.event, territory = game.battle.territory;
const dialClaim = { kind: 'dial', compare: 'gte', value: 1 } as const;
const mixed = group('and', plan(event, territory, dialClaim), payment(f.asker));
const open = (state: Game, card: string, question: TruthQuestion): Game => {
let next = act(state, f.asker, { type: 'card', card }, actions);
while (next.truthtrance?.stage === 'priority') {
const actor = next.players.find(candidate => !next.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
next = act(next, actor.id, { type: 'truthPass' }, actions);
}
return act(next, f.asker, { type: 'truthAsk', question }, actions);
};
game = open(game, f.truths[0], { kind: 'turnActions', target: f.target, turn: game.turn, mixed });
game = answer(game, f.target, 'no', actions);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
const earlier = structuredClone(game.turnActionPromises![0]), earlierHistory = structuredClone(game.truthHistory![0]);
assert.equal(player(game, f.target).spice, 8); assert.equal(game.bribePromises?.length ?? 0, 0);
game = open(game, f.truths[1], { kind: 'battlePlan', target: f.target, territory, claim: dialClaim });
assert.deepEqual(viewGame(game, f.target).truthBattleAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.turnActionPromises![0], earlier); assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
game = answer(game, f.target, 'no', actions);
for (const actor of [f.target, opponent]) {
const leader = player(game, actor).leaders.find(disc => !disc.dead && !disc.usedAt); assert.ok(leader);
game = act(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0,
weapon: null, defense: null }, actions);
}
assert.ok(game.battle?.revealed); assert.equal(game.battle.plans[f.target].dial, 0);
game = finish(game, actions);
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
assert.equal(game.truthHistory!.length, 2);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), originalTraitors);
});
+189
View File
@@ -9,12 +9,15 @@ import {
TURN_ACTION_MAX_LEAVES,
TurnActionError,
closeTurnActionBidding,
closeTurnActionCombat,
closeTurnActionRevival,
closeTurnActionTransport,
finishTurnActionTurn,
liveTurnActionPromises,
parseTurnActionLeaf,
recordTurnActionBid,
recordTurnActionPlan,
recordTurnActionTraitor,
recordTurnActionMovement,
recordTurnActionPayment,
recordTurnActionShipment,
@@ -27,6 +30,7 @@ import {
type TurnActionBranchLeaf,
type TurnActionLeaf,
type TurnActionPromise,
type TurnActionPlanValue,
} from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
@@ -40,6 +44,10 @@ const forces = (minimum = 3, counter: 'total' | 'normal' | 'elite' = 'total'): E
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leader = (leader = 'atreides-0'): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const alliance = (partner = 'recipient'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const plan = (claim: TurnActionPlanValue['claim'] = { kind: 'dial', compare: 'gte', value: 2 }, battle = 'battle:1'): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle, territory: 'carthag', claim } });
const call = (call = true, battle = 'battle:1'): Expression =>
({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
@@ -66,6 +74,12 @@ function prepared(mixed: Expression, answer = true, id = 'turn-action:1'): TurnA
function nexus(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
return { ...promise(mixed, answer, id), alliance: { source: 'nexus:1', partner: null, closed: false } };
}
function combat(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
return { ...promise(mixed, answer, id), combat: { event: 'battle:1', territory: 'carthag',
plan: { closed: false, values: null }, traitor: { beneficiary: 'respondent', target: 'other',
leader: 'other-0', closed: false, call: null } } };
}
const dial = (value = true): TurnActionPlanValue => ({ claim: { kind: 'dial', compare: 'gte', value: 2 }, value });
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
for (const kind of ['ship', 'move'] as const) {
@@ -683,3 +697,178 @@ void test('final-membership stages reject temporary pairs, extra metadata, missi
const foreignCompiled = { ...original, compiled: { kind: 'action', action: { kind: 'alliance', partner: 'other' } } };
assert.throws(() => turnActionValue(foreignCompiled as TurnActionPromise), TurnActionError);
});
void test('combat leaves accept only one original native plan atom or actual declaration in the original battle', () => {
const claims: TurnActionPlanValue['claim'][] = [{ kind: 'dial', compare: 'eq', value: 1.5 },
{ kind: 'support', compare: 'gte', value: 2 }, { kind: 'weapon', name: null },
{ kind: 'defense', name: null }, { kind: 'leader', leader: 'structural-leader-id' }, { kind: 'kwisatz', use: false }];
for (const claim of claims) {
const leaf = { kind: 'battlePlan', battle: 'battle:1', territory: 'carthag', claim };
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
}
for (const choice of [true, false]) assert.deepEqual(parseTurnActionLeaf({ kind: 'traitorCall', battle: 'battle:1',
territory: 'carthag', call: choice }), { kind: 'traitorCall', battle: 'battle:1', territory: 'carthag', call: choice });
const original = { kind: 'battlePlan', battle: 'battle:1', territory: 'carthag', claim: dial().claim };
for (const leaf of [{ ...original, battle: '' }, { ...original, territory: '' }, { ...original, answer: true },
{ ...original, claim: { kind: 'and', terms: [dial().claim, dial().claim] } },
{ ...original, claim: { kind: 'dial', compare: 'eq', value: 1.25 } },
{ ...original, claim: { kind: 'leader', leader: ' untrimmed' } },
{ kind: 'traitorCall', battle: 'battle:1', territory: 'carthag', call: 'yes' },
{ kind: 'traitorCall', battle: 'battle:1', territory: 'carthag', call: true, canReveal: true }])
assert.throws(() => parseTurnActionLeaf(leaf), TurnActionError);
for (const mixed of [plan(), call(), group('or', fact(), plan()), group('or', fact(), call())])
assert.throws(() => turnActionValue(promise(mixed)), TurnActionError);
});
void test('combat whole AND OR and No branches retain original plan atoms and actual declaration polarity', () => {
const planLeaf = { kind: 'battlePlan', battle: 'battle:1', territory: 'carthag', claim: dial().claim, answer: true };
const traitorLeaf = { kind: 'traitorCall', battle: 'battle:1', territory: 'carthag', call: true, answer: true,
beneficiary: 'respondent', target: 'other', leader: 'other-0' };
assert.deepEqual(branches(combat(group('and', plan(), call(), pay()))), [[planLeaf, traitorLeaf, signedPay()]]);
assert.deepEqual(branches(combat(group('or', plan(), call(), pay()))), [[planLeaf], [traitorLeaf], [signedPay()]]);
assert.deepEqual(branches(combat(group('and', plan(), call()), false)), [
[{ ...planLeaf, answer: false }], [{ ...traitorLeaf, answer: false }],
]);
assert.deepEqual(branches(combat(group('or', call(true), call(false)), false)), [],
'No declaration satisfies No to the OR of actual Reveal and actual Decline');
assert.deepEqual(branches(combat(group('and', call(true), call(false)))), [],
'One original native declaration cannot both Reveal and Decline');
assert.deepEqual(branches(combat(group('and', plan(), plan()))), [[planLeaf]]);
});
void test('one actual plan receipt projects exact original predicates into distinct answer intervals and then permits later payment', () => {
const weapon: TurnActionPlanValue = { claim: { kind: 'weapon', name: null }, value: true };
const first = combat(group('and', plan(), pay()));
const second = combat(group('and', plan(weapon.claim), plan(weapon.claim), pay()), true, 'turn-action:2');
const rows = recordTurnActionPlan([first, second], 'respondent', 1, 'battle:1', 'carthag', [dial(), weapon]);
assert.deepEqual(rows[0].combat!.plan, { closed: true, values: [dial()] });
assert.deepEqual(rows[1].combat!.plan, { closed: true, values: [weapon] });
assert.deepEqual(branches(rows[0]), [[signedPay()]]);
assert.equal(rows[0].fulfilled, false);
assert.equal(recordTurnActionPayment(rows, 'respondent', 1, payment())[0].fulfilled, true);
const fresh = combat(group('and', plan(), pay()), true, 'turn-action:3');
assert.equal(fresh.combat!.plan!.values, null);
assert.equal(recordTurnActionPlan(rows, 'respondent', 1, 'battle:1', 'carthag', [dial(false), weapon])[0], rows[0]);
assert.equal(recordTurnActionPlan([fresh], 'respondent', 1, 'other-battle', 'carthag', [dial()])[0], fresh);
assert.equal(recordTurnActionPlan([fresh], 'respondent', 1, 'battle:1', 'arrakeen', [dial()])[0], fresh);
assert.equal(recordTurnActionPlan([fresh], 'respondent', 2, 'battle:1', 'carthag', [dial()])[0], fresh);
assert.equal(recordTurnActionPlan([fresh], 'other', 1, 'battle:1', 'carthag', [dial()])[0], fresh);
assert.equal(first.combat!.plan!.values, null);
validateTurnActionPromises(rows, seats);
});
void test('actual plan and declaration events reject whole No violations atomically and retain dynamic alternatives', () => {
const no = combat(group('and', plan(), call()), false);
const planned = recordTurnActionPlan([no], 'respondent', 1, 'battle:1', 'carthag', [dial()]);
const before = structuredClone(planned);
assert.throws(() => recordTurnActionTraitor(planned, 'respondent', 1, 'battle:1', 'carthag', true), TurnActionError);
assert.deepEqual(planned, before);
const declined = recordTurnActionTraitor(planned, 'respondent', 1, 'battle:1', 'carthag', false);
assert.equal(declined[0].fulfilled, true);
const yes = combat(group('and', plan(), pay()), true, 'turn-action:2');
const planNo = combat(plan(), false, 'turn-action:3');
assert.throws(() => recordTurnActionPlan([yes, planNo], 'respondent', 1, 'battle:1', 'carthag', [dial()]), TurnActionError);
assert.equal(yes.combat!.plan!.values, null);
const declineNo = combat(call(false), false);
assert.throws(() => recordTurnActionTraitor([declineNo], 'respondent', 1, 'battle:1', 'carthag', false), TurnActionError);
const revealed = recordTurnActionTraitor([combat(group('and', call(), pay()))], 'respondent', 1, 'battle:1', 'carthag', true);
assert.equal(revealed[0].combat!.traitor!.call, true);
assert.equal(recordTurnActionTraitor(revealed, 'respondent', 1, 'battle:1', 'carthag', false)[0], revealed[0],
'Later prevention/effect handling cannot retract the original declaration');
});
void test('combat closure without a receipt makes every plan and both declaration predicates false', () => {
const zero = plan({ kind: 'dial', compare: 'eq', value: 0 });
const yes = combat(group('or', zero, call(false)));
const no = combat(group('or', zero, call(false)), false, 'turn-action:2');
const closed = closeTurnActionCombat([yes, no], 1, 'battle:1');
assert.equal(turnActionValue(closed[0]), false);
assert.deepEqual(closed[0].combat!.plan, { closed: true, values: null });
assert.equal(closed[0].combat!.traitor!.closed, true);
assert.equal(closed[0].combat!.traitor!.call, null);
assert.equal(closed[0].fulfilled, false);
assert.equal(closed[1].fulfilled, true);
assert.equal(closeTurnActionCombat([yes], 1, 'other-battle')[0], yes);
const finished = finishTurnActionTurn(closed, 'respondent', 1);
assert.equal(finished[0].released, true);
const received = recordTurnActionPlan([combat(group('and', plan(), pay()))], 'respondent', 1, 'battle:1', 'carthag', [dial()]);
const preserved = closeTurnActionCombat(received, 1, 'battle:1');
assert.deepEqual(preserved[0].combat!.plan, { closed: true, values: [dial()] });
assert.deepEqual(branches(preserved[0]), [[signedPay()]]);
});
void test('combat expiry releases unresolved original stages without inventing a plan or declaration and preserves terminal rows', () => {
const yes = combat(plan()), no = combat(call(false), false, 'turn-action:2');
const expired = finishTurnActionTurn([yes, no], 'respondent', 1);
for (const row of expired) {
assert.equal(row.released, true);
assert.equal(row.fulfilled, false);
assert.deepEqual(row.combat, yes.combat);
}
const terminal = recordTurnActionPlan([combat(plan(), true, 'turn-action:3')], 'respondent', 1, 'battle:1', 'carthag', [dial()])[0];
assert.equal(terminal.fulfilled, true);
assert.equal(finishTurnActionTurn([terminal], 'respondent', 1)[0], terminal);
for (const transform of [
(rows: TurnActionPromise[]) => closeTurnActionCombat(rows, 1, 'battle:1'),
(rows: TurnActionPromise[]) => recordTurnActionPlan(rows, 'respondent', 1, 'battle:1', 'carthag', [dial()]),
(rows: TurnActionPromise[]) => recordTurnActionTraitor(rows, 'respondent', 1, 'battle:1', 'carthag', true),
]) assert.ok(transform([...expired, terminal]).every((row, index) => row === [...expired, terminal][index]));
validateTurnActionPromises([...expired, terminal], seats);
});
void test('combat metadata rejects missing original stages, mismatched source, premature values and incomplete or foreign receipts', () => {
const original = combat(group('and', plan(), call(), pay()));
const stage = original.combat!;
for (const value of [undefined, null, {}, { ...stage, event: 'other-battle' }, { ...stage, territory: 'arrakeen' },
{ ...stage, plan: null }, { ...stage, traitor: null }, { ...stage, canReveal: true },
{ ...stage, plan: { closed: false, values: [dial()] } },
{ ...stage, plan: { closed: true, values: [] } },
{ ...stage, plan: { closed: true, values: [dial(), dial()] } },
{ ...stage, plan: { closed: true, values: [{ claim: { kind: 'weapon', name: null }, value: true }] } },
{ ...stage, traitor: { ...stage.traitor!, call: false } },
{ ...stage, traitor: { ...stage.traitor!, beneficiary: 'other', target: 'other' } }])
assert.throws(() => turnActionValue({ ...original, combat: value } as TurnActionPromise), TurnActionError);
assert.throws(() => recordTurnActionPlan([original], 'respondent', 1, 'battle:1', 'carthag', []), TurnActionError);
assert.throws(() => recordTurnActionPlan([original], 'respondent', 1, 'battle:1', 'carthag', [dial(), dial()]), TurnActionError);
assert.throws(() => recordTurnActionPlan([original], 'respondent', 1, 'battle:1', 'carthag',
[{ ...dial(), value: 'yes' }] as never), TurnActionError);
assert.throws(() => recordTurnActionTraitor([original], 'respondent', 1, 'battle:1', 'carthag', null as never), TurnActionError);
const foreign = { ...original, combat: { ...stage, traitor: { ...stage.traitor!, target: 'missing' } } };
assert.throws(() => validateTurnActionPromises([foreign], seats), TurnActionError);
});
void test('combat clauses share the outer logical budget and can coexist with strict earlier optional stages', () => {
const sixteen = combat(group('or', ...Array.from({ length: 16 }, () => plan())));
assert.doesNotThrow(() => turnActionValue(sixteen));
assert.throws(() => turnActionValue(combat(group('or', ...Array.from({ length: 17 }, () => plan())))), TurnActionError);
const mixed = group('and', plan(), call(), bid(), forces(), alliance(), pay());
const row = { ...prepared(mixed), alliance: { source: 'nexus:1', partner: null, closed: false }, combat: combat(mixed).combat };
validateTurnActionPromises([row], seats);
const old = prepared(group('and', bid(), forces(), pay()));
assert.equal('combat' in closeTurnActionCombat([old], 1, 'battle:1')[0], false);
});
void test('native off-board combat locations retain exact event and location bindings without widening travel destinations', () => {
const territory = 'homeworld:guild';
const mixed: Expression = { kind: 'and', terms: [
{ kind: 'action', action: { kind: 'battlePlan', battle: 'battle:1', territory, claim: dial().claim } },
{ kind: 'action', action: { kind: 'traitorCall', battle: 'battle:1', territory, call: false } },
pay(),
] };
const original = combat(mixed);
original.combat!.territory = territory;
validateTurnActionPromises([original], seats);
assert.equal(recordTurnActionPlan([original], 'respondent', 1, 'other-battle', territory, [dial()])[0], original);
assert.equal(recordTurnActionPlan([original], 'respondent', 1, 'battle:1', 'homeworld:emperor', [dial()])[0], original);
const planned = recordTurnActionPlan([original], 'respondent', 1, 'battle:1', territory, [dial()]);
assert.equal(recordTurnActionTraitor(planned, 'respondent', 1, 'other-battle', territory, false)[0], planned[0]);
assert.equal(recordTurnActionTraitor(planned, 'respondent', 1, 'battle:1', 'homeworld:emperor', false)[0], planned[0]);
const declared = recordTurnActionTraitor(planned, 'respondent', 1, 'battle:1', territory, false);
assert.deepEqual(branches(declared[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(declared, 'respondent', 1, payment())[0].fulfilled, true);
assert.equal(closeTurnActionCombat([original], 1, 'other-battle')[0], original);
assert.equal(closeTurnActionCombat([original], 1, 'battle:1')[0].combat!.plan!.closed, true);
for (const kind of ['ship', 'move'])
assert.throws(() => parseTurnActionLeaf({ kind, territory, minimum: 1 }), TurnActionError);
});