diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 5b8cc3e3..8d853bb4 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -581,7 +581,7 @@ export function Truthtrance({ Combine current facts and ordinary bids Bind this battle’s Traitor declaration Combine current facts and this Traitor declaration - Combine this turn’s bids, Revival, payments, shipment and ground movements + Combine this Nexus’s alliances with this turn’s bids, Revival, payments, shipment and ground movements Combine this turn’s shipment and ground movements Bind a shipment from reserves @@ -602,7 +602,7 @@ export function Truthtrance({ <> - {!g.truthTurnActionTargets.includes(target) && Choose a player with a supported ordinary bid, Revival, payment, shipment or ground-movement opportunity this turn. + {!g.truthTurnActionTargets.includes(target) && Choose a player with a supported alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunity this turn. Ordinary payments may remain available after their Shipment and Movement opportunity ends.} {turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)} > diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx index 4f8c666b..dcc0e4a3 100644 --- a/components/turn-action-fields.tsx +++ b/components/turn-action-fields.tsx @@ -20,11 +20,16 @@ const leaders = (game: GameView, target: string) => { ...(game.advanced && faction === 'atreides' ? [{ id: 'kwisatz', name: 'Kwisatz Haderach' }] : [])]; }; const currentLot = (game: GameView) => game.bidPromiseLot?.turn === game.turn ? game.bidPromiseLot : null; +const partners = (game: GameView, target: string) => game.players.some(player => player.id === target) + ? game.players.filter(player => player.id !== target) : []; +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 config: MixedActionConfig = { prefix: 'turn-action', - legend: 'Future bids, Revival, payments, shipment and ground movements, with optional current facts', - actionLabel: 'bid, Revival, payment, shipment or ground-movement condition', - actionTiming: 'Future ordinary bid, Revival, payment, reserve shipment or ground move · this turn', + 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', 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's Mentat opening; collection never counts twice. @@ -33,13 +38,16 @@ const config: MixedActionConfig = { Ordinary Revival conditions count cumulative future normal and elite physical force returns, with each counter counted once, or an actual future return of a named native leader. Smaller ordinary force returns add together. Earlier returns, requests alone, Ghola, foreign leaders and special card returns do not count. + 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's consent. Shipment conditions concern the single future original reserve shipment after the answer. Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together. Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity. Shipment and movement close at your own Movement turn end, while payment conditions last until the actual turn ends. AND requires every condition; OR keeps legal alternatives. No makes the whole expression false, not every condition separately. Original costs, timing, cards, preparation and action rights still apply; this claim grants no extra action.>, - fallbackError: 'Choose a future ordinary bid, Revival, payment, reserve shipment or ground-movement condition, with optional current facts.', + fallbackError: 'Choose a future alliance, ordinary bid, Revival, payment, reserve shipment or ground-movement condition, with optional current facts.', maxLeaves: TURN_ACTION_MAX_LEAVES, maxDepth: TURN_ACTION_MAX_DEPTH, requireFact: false, @@ -52,16 +60,19 @@ const config: MixedActionConfig = { throw new Error('Choose the current public ordinary lot for the future bid.'); if (action.kind === 'leader' && !leaders(game, target).some(leader => leader.id === action.leader)) throw new Error('Choose a public native leader for the respondent’s faction.'); + 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.'); return action; }, renderAction: ({ value, id, game, target, disabled, onChange }) => { - const choices = recipients(game, target), leaderChoices = leaders(game, target), lot = currentLot(game); + const choices = recipients(game, target), leaderChoices = leaders(game, target), lot = currentLot(game), partnerChoices = partners(game, target); return <> Future action { const kind = event.currentTarget.value; if (kind === value.kind) return; - const minimum = value.kind === 'leader' ? 1 : value.minimum; + const minimum = value.kind === 'leader' || value.kind === 'alliance' ? 1 : value.minimum; + if (kind === 'alliance') onChange({ kind, partner: '' }); if (kind === 'payment') onChange({ kind, recipient: '', minimum }); if (kind === 'bid') onChange({ kind, lot: lot?.lot ?? NaN, minimum }); if (kind === 'forces') onChange({ kind, counter: 'total', minimum }); @@ -69,6 +80,7 @@ const config: MixedActionConfig = { if (kind === 'ship' || kind === 'move') onChange({ kind, territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum }); }}> + End this Nexus allied with a named partner Place an ordinary-lot bid Revive ordinary physical forces Revive a named ordinary leader @@ -76,7 +88,18 @@ const config: MixedActionConfig = { Ship from reserves Make ground movement - {value.kind === 'bid' ? <> + {value.kind === 'alliance' ? <> + Final Nexus alliance partner + partner.id === value.partner) ? value.partner : ''} + disabled={disabled} aria-invalid={!partnerChoices.some(partner => partner.id === value.partner)} onChange={event => { + const partner = event.currentTarget.value; + if (partner === '' || partnerChoices.some(choice => choice.id === partner)) onChange({ ...value, partner }); + }}> + Choose a partner + {partnerChoices.map(partner => {partner.name})} + + {!currentNexus(game, target) && Alliance conditions require the current open Nexus.} + > : value.kind === 'bid' ? <> Current ordinary lot { @@ -128,7 +151,7 @@ const config: MixedActionConfig = { {TERRITORIES.map(destination => {destination.name})} >} - {value.kind !== 'leader' && <> + {value.kind !== 'leader' && value.kind !== 'alliance' && <> {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' diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx index 90ea17d1..0bf9d0b0 100644 --- a/components/turn-action-promises.tsx +++ b/components/turn-action-promises.tsx @@ -2,12 +2,17 @@ import type { Action, GameView } from '@/game/engine'; import { bribeAction } from '@/game/bribe-options'; +import { alliancePromiseModeSupported } from '@/game/alliance-promises'; import { nativePromiseScopeSupported } from '@/game/native-promise-scope'; +import { quoteNexusAlliance } from '@/game/nexus-alliance'; 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 }; +const publicAllianceContext = (game: GameView) => ({ + players: game.players.map(player => ({ id: player.id, ally: player.ally })), offers: game.allianceOffers, +}); /** One projected native packet; no client branch selection or completion solver. */ export function TurnActionGuideButton(props: TurnActionProps) { @@ -28,6 +33,8 @@ export function TurnActionGuideButton(props: TurnActionProps) { passed: current.richeseAuction.passed, acted: current.richeseAuction.acted, }); const auctionSignature = auctionSource(game), nativeSignature = nativeSource(game), orderSignature = JSON.stringify(order); + const nexus = game.alliancePromiseNexus, nexusOpen = game.nexus, nexusSpiceWindow = game.spiceWindow; + const nexusSignature = JSON.stringify({ ...publicAllianceContext(game), ready: game.ready }); const box = game.nullentropy, search = box?.search; const canAct = () => { const current = props.game, me = current.players.find(player => player.id === actor); @@ -61,7 +68,16 @@ export function TurnActionGuideButton(props: TurnActionProps) { const revivalTurn = current.revival === revival && !current.revival.pending && !current.revival.orderBlocked && current.revivalOrder === order && JSON.stringify(current.revivalOrder) === orderSignature && (!order || order.turn === turn && order.remaining[0] === actor && current.active === actor); - const opportunity = payment || paidBox || (phase === 2 + const nexusWindow = phase === 1 && nexusOpen && current.nexus === nexusOpen && !nexusSpiceWindow && + current.spiceWindow === nexusSpiceWindow && !!nexus && + current.alliancePromiseNexus === nexus && nexus.turn === turn && !current.ready.includes(actor) && + alliancePromiseModeSupported(current) && JSON.stringify({ ...publicAllianceContext(current), ready: current.ready }) === nexusSignature; + let ownedAlliance = action.type === 'alliance' && action.target === null; + if (action.type === 'alliance' && typeof action.target === 'string') { + try { ownedAlliance = !!quoteNexusAlliance({ ...publicAllianceContext(current), actor, target: action.target }).formed; } + catch { ownedAlliance = false; } + } + const opportunity = payment || paidBox || 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 @@ -70,7 +86,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { : phase === 5 ? current.active === actor && (action.type === 'guildShip' && action.from === 'reserves' || ['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type)) - : (phase === 1 && current.nexus && !current.spiceWindow || phase === 7 || phase === 8) && + : (phase === 7 || phase === 8) && action.type === 'ready' && !current.ready.includes(actor)); return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' && Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor && @@ -88,9 +104,14 @@ 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 === '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' + : `Accept ${recipient}’s alliance invitation while preserving your whole answer` : action.type === 'charity' ? 'Claim Charity while preserving your whole answer' : action.type === 'ready' ? phase === 8 ? 'Ready to end this turn while honoring your whole answer' - : 'Finish this preparation phase while preserving your whole answer' + : phase === 1 ? 'Confirm Ready for this Nexus while preserving your whole answer' + : 'Finish this preparation phase while preserving your whole answer' : action.type === 'passBid' ? 'Pass this bid while preserving your whole answer' : action.type === 'bid' ? `Place the required bid of ${amount} while preserving your whole answer` : action.type === 'richeseBid' ? action.amount === 0 ? 'Submit zero in this Silent auction while preserving your whole answer' @@ -119,7 +140,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 - Your bid, Revival, payment, shipment and movement promises + Your alliance, bid, Revival, payment, shipment and movement promises {own.map(promise => 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' @@ -141,6 +162,9 @@ export function TurnActionPromises(props: TurnActionProps) { Force Revival conditions count cumulative future ordinary returns; normal and elite physical counters each count once. Named leader conditions require an actual future ordinary native return. Earlier returns, requests alone, Ghola, foreign leaders and special card returns do not count. + 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. 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. @@ -170,11 +194,13 @@ export function TurnActionAnswers(props: TurnActionProps) { !!me && !me.autopilot && !current.response && !current.decision && !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; }; - return + return 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. 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. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 44512701..df3059f0 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,44 @@ open mode gates. ## Current checkpoint and work +**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, +pairs and own Ready never become final membership. Actual Nexus close records +the partner once; whole No can permit a paid temporary pair followed by a break. +Own Ready preserves a completion after that snapshot, including later payment +alternatives. Existing reciprocal consent, withdrawal rights, prices and stages +remain. Missing consent permits Unknown only for an otherwise funded native +branch; known financial impossibility stays No. No foreign consent or selected +route enters real state. Older rows remain valid without alliance metadata. + +Root integration with disjoint helper/control/native lanes and bounded review +passes **19 files,221/221**, types/lint/build and **four native paths**,37 +GameTable SSR renders/four actual answer callbacks/16 guide callbacks. +Original source47 adds21 for final pair, pay2 and later paid shipment1. Original +allied source48 adds29 for own Ready, pay2, required break and No through actual +turn end. Source47 adds24 for final None followed by late Mentat payment. +No-consent source46 adds6 for actual Unknown and original TruthSave, with both +physical Truths retained. Original inventories remain; no played source state +is installed. + +Twenty-eight helper,21 control and18 new native cases cover final-source timing, +whole alternatives, consent/known finance, temporary pairs, foreign withdrawal, +late payments, uniform readiness including constants, proposal/payment funding, +exact own guides and four minimal profiles. Review repairs an Unknown path that +ignored known cost. A detached consent witness is resource-checked and cannot +supply Yes or a guide. Runtime-first v1 catches its driver's assumption that a +legal No guide chooses Ready; v2 explicitly takes the legal manual Ready path +before testing the later required break, then passes all checks on the same +source. No repeated source checks are spent on that driver repair. +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-alliance-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-turn-alliance-runtime/`. +Current battle-plan/Traitor compositions, wider preparations/modules and final +rules/browser/deployed/recovery/privacy/custody acceptance remain. No database/ +save/server/reset/restart/deployment operation is performed; no full mode is +certified. [Contract and source](TRUTHTRANCE_TURN_ACTIONS.md#combined-final-nexus-membership--26-october-2026). + **Truthtrance / combined ordinary bidding and Revival — 26 October. Prototyped / Partial.** The whole current-turn question now includes original ordinary-lot bids, typed ordinary force returns and personal native leader returns alongside payments diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index ebb55340..30397ed7 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,45 @@ # Dune implementation status +## 26 October 2026 — Combined final Nexus membership + +**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, +pairs and own Ready never become final membership. Actual Nexus close records +the partner once; whole No can permit a paid temporary pair followed by a break. +Own Ready preserves a completion after that snapshot, including later payment +alternatives. Existing reciprocal consent, withdrawal rights, prices and stages +remain. Missing consent permits Unknown only for an otherwise funded native +branch; known financial impossibility stays No. No foreign consent or selected +route enters real state. Older rows remain valid without alliance metadata. + +Root integration with disjoint helper/control/native lanes and bounded review +passes **19 files,221/221**, types/lint/build and **four native paths**,37 +GameTable SSR renders/four actual answer callbacks/16 guide callbacks. +Original source47 adds21 for final pair, pay2 and later paid shipment1. Original +allied source48 adds29 for own Ready, pay2, required break and No through actual +turn end. Source47 adds24 for final None followed by late Mentat payment. +No-consent source46 adds6 for actual Unknown and original TruthSave, with both +physical Truths retained. Original inventories remain; no played source state +is installed. + +Twenty-eight helper,21 control and18 new native cases cover final-source timing, +whole alternatives, consent/known finance, temporary pairs, foreign withdrawal, +late payments, uniform readiness including constants, proposal/payment funding, +exact own guides and four minimal profiles. Review repairs an Unknown path that +ignored known cost. A detached consent witness is resource-checked and cannot +supply Yes or a guide. Runtime-first v1 catches its driver's assumption that a +legal No guide chooses Ready; v2 explicitly takes the legal manual Ready path +before testing the later required break, then passes all checks on the same +source. No repeated source checks are spent on that driver repair. +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-alliance-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-turn-alliance-runtime/`. +Current battle-plan/Traitor compositions, wider preparations/modules and final +rules/browser/deployed/recovery/privacy/custody acceptance remain. No database/ +save/server/reset/restart/deployment operation is performed; no full mode is +certified. [Contract and source](TRUTHTRANCE_TURN_ACTIONS.md#combined-final-nexus-membership--26-october-2026). + ## 26 October 2026 — Combined ordinary bids and Revival **Truthtrance / combined ordinary bidding and Revival — 26 October. Prototyped / Partial.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 340845da..0cd602dc 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,21 @@ # Rule decision index +## Combined final Nexus membership — 26 October 2026 + +Compose final current-Nexus membership with the whole native turn-action +question, reusing existing publisher FAQp8/current-turn and supplied Advanced +physical39 compliance/release authority plus the recorded reciprocal-alliance +contract. Invitations and temporary pairs are not final membership. Actual +original Nexus close fixes the partner; later payment/physical alternatives +keep their own deadlines. Own Ready must retain completion after that snapshot. +Definite answers and later ordinary payer actions uniformly reopen own Nexus +readiness, including folded constants. Missing consent can permit Unknown/save +only for an otherwise funded native branch, never an invented Yes; known +unaffordability remains No. Signed native partner witnesses are private and +never become a stored route. Existing pair/mode scope and other players' +withdrawal rights remain. Broader action/module and full-mode acceptance stays +open. [Contract/evidence](TRUTHTRANCE_TURN_ACTIONS.md#combined-final-nexus-membership--26-october-2026). + ## Combined ordinary bids and Revival — 26 October 2026 Extend the recorded current-turn AND/OR composition to the original ordinary diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index 43aad2a1..7fef038d 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -170,3 +170,70 @@ families and full-mode acceptance remain unfinished. Existing native stage promises retain their original scope. No comprehensive rule/recovery/privacy/ custody or deployed gate opens, and this batch performs no database/save/server/ reset/restart/deployment operation. + +## Combined final Nexus membership — 26 October 2026 + +**Prototyped / Partial.** Add `{kind:'alliance',partner}` to the existing +whole current-turn grammar, only during the current quiet ordinary native Nexus. +Every raw partner is seated and different from the respondent, even when facts +fold its branch away. Existing native consent and pairing scope remains. + +Optional private `alliance:{source,partner,closed}` binds the original Nexus. +An open stage has no recorded final partner. Temporary invitations and pairs do +not fulfill a clause; actual source close snapshots final membership. Whole No +may permit a temporary pair and later require a break. Later ordinary payments +and native preparation/transport retain their original separate deadlines. + +Native signed partner constraints reuse the existing reciprocal consent plans, +while original payment and physical resource quotes fund the whole branch. No +selected route or final-state witness enters real native ledgers or public +views. Missing foreign consent cannot create a definite Yes; existing native +Unknown/save/reask semantics require an otherwise funded native branch. A +detached consent-only witness can establish Unknown, never Yes or a guide; it +still runs native pairing, payment and later-stage resource checks. Known +financial impossibility, including when consent is also absent, stays No. +Every definite answer uniformly reopens own Nexus readiness, including constants. +Actual closing preflight requires a whole completion after final membership is +fixed; later actions retain any residual payment commitment. Actual ordinary +payments uniformly reopen own current-Nexus readiness for the raw alliance +history, including privately folded terminal constants. A new payment can +require a later break before Ready. An owed payment is protected before offering +its recipient a partnership; compatible alternatives remain available. + +The frozen source passes **19 files,221/221**, types/lint, production build and +four native paths with37 GameTable SSR renders, four actual answer callbacks +and16 guide callbacks. Original source47 +21 forms the final pair, pays2 and +later ships1 for its real fee2. Allied source48 +29 performs own Ready, pay2, +required break and whole No through actual turn end. Source47 +24 fixes None +at the Nexus, then pays during late Mentat. No-consent source46 +6 answers +Unknown and saves the actual original Truth, retaining both physical cards and +installing no partner or promise. All original physical inventories remain; +no played state is installed. These are local engine/SSR/callback journeys, +not browser, hydration or deployed acceptance. + +Twenty-eight helper and21 control cases support18 new native cases. They cover +actual final source close, both OR routes, paid temporary membership, whole No, +later payment, foreign invitation withdrawal and departure, no reactivation, +exact current own packets, uniform definite-answer readiness and four profiles. +Focused additions distinguish known unaffordability even when consent is also +missing, require renewed Ready after payment, protect an owed payee before a +prospective alliance, and keep terminal constant receipts unchanged while +uniformly reopening readiness. + +Bounded review finds an Unknown path that checked consent before the branch's +money. A tagged private consent scenario now runs native pairing and all other +resource checks; it can supply Unknown only, never a definite answer or guide. +V1's runtime-first proof catches a private-driver assumption: the legal No guide +may choose a break instead of Ready. V2 explicitly takes the legal manual Ready +route before the payment/break sequence. Source is unchanged by that driver +repair; types/lint, related tests and build run once after the smoke passes. + +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-alliance-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-turn-alliance-runtime/`. + +Current battle-plan and Traitor-declaration compositions, wider native +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. diff --git a/game/engine.ts b/game/engine.ts index dc53d1c2..184a57df 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,7 +1,7 @@ import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment, recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn, validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, - closeTurnActionRevival, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises'; + closeTurnActionRevival, settleTurnActionAlliance, 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'; @@ -26,7 +26,7 @@ import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequ leaderRevivalPromisesSatisfied, recordPromisedLeaderRevival, validateLeaderRevivalPromises, type LeaderRevivalClaim, type LeaderRevivalPromise } from './leader-revival-promises'; import { AlliancePromiseError, alliancePromiseModeSupported, - alliancePromisesMatch, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises'; + alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises'; import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe, validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises'; import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; @@ -15036,6 +15036,16 @@ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) { requireRule(!!bribePromiseCompletion(future, actor), 'Pay the necessary ordinary bribe before offering an alliance that would prevent your whole answer.'); } + const namedPayment = (node: TurnActionClaim['mixed']): boolean => node.kind === 'action' + ? node.action.kind === 'payment' && node.action.recipient === other.id + : node.kind !== 'fact' && node.terms.some(namedPayment); + if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).some(promise => namedPayment(promise.mixed))) { + const future = structuredClone(g), owner = getPlayer(future, p.id), partner = getPlayer(future, other.id); + for (const seat of future.players) if (seat.ally === owner.id || seat.ally === partner.id) seat.ally = null; + owner.ally = partner.id; partner.ally = owner.id; + requireRule(!!turnActionCompletion(future, owner.id), + 'Pay the necessary ordinary bribe before offering an alliance that would prevent your whole turn answer.'); + } const blocked = homeworldAllianceReason(g, p.id, other.id); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); } @@ -15231,6 +15241,10 @@ function guardAlliancePromiseReady(g: Game, p: Player) { requireRule(alliancePromisesMatch(g.alliancePromises ?? [], p.id, opportunity, p.ally) && allianceExpressionPromisesMatch(g.allianceExpressionPromises ?? [], p.id, opportunity, p.ally), 'Match your Truthtrance alliance answers before finishing this Nexus.'); + if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn) + .some(promise => promise.alliance?.source === opportunity.source && !promise.alliance.closed)) + requireRule(turnActionAllianceCanClose(g, p), + 'Retain a whole Truthtrance completion when this Nexus fixes final membership.'); } function settleAlliancePromises(g: Game) { const opportunity = allianceOpportunity(g); @@ -15239,6 +15253,7 @@ function settleAlliancePromises(g: Game) { for (const promise of orderedAllianceCommitments(g)) if (promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.fulfilled && !promise.released) promise.fulfilled = true; + if (g.turnActionPromises) g.turnActionPromises = turnActionAllianceSnapshot(g, opportunity); } function projectedAllianceExpressionPromises(g: Game, player: string) { return (g.allianceExpressionPromises ?? []).filter(promise => promise.player === player && !isMixedAllianceEvent(g, promise.event)) @@ -18708,10 +18723,86 @@ function initializeTurnActionOrder(g: Game) { if (!g.travelPromiseOrder!.some(entry => entry.kind === kind && entry.index === index)) g.travelPromiseOrder!.push({ kind, index }); } -type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan }; +/** All admissible final native partners use the original reciprocal quote. + * Exact outcome rows live only in this witness; no route is persisted. */ +type TurnActionAllianceRoute = { steps: { player: string; target: string | null }[]; uncertain: boolean }; +function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false): (TurnActionAllianceRoute | undefined)[] { + const scalars = g.alliancePromises ?? [], groups = g.allianceExpressionPromises ?? []; + const active = [...scalars, ...groups].some(promise => promise.player === actor && + promise.turn === g.turn && !promise.fulfilled && !promise.released); + if (!active) return [undefined]; + const opportunity = allianceOpportunity(g); + if (!opportunity) return []; + if ([...scalars, ...groups].some(promise => promise.player === actor && promise.turn === g.turn && + !promise.fulfilled && !promise.released && promise.source !== opportunity.source)) return []; + const outcomes = allianceExpressionOutcomes(scalars, groups, actor, opportunity, + { players: g.players, offers: g.allianceOffers }); + const plans: TurnActionAllianceRoute[] = [], seen = new Set(); + for (const partner of outcomes) { + const exact: AlliancePromise[] = (partner === null ? g.players.filter(player => player.id !== actor) + .map(player => ({ partner: player.id, answer: false })) : [{ partner, answer: true }]) + .map((claim, index) => ({ ...claim, turn: opportunity.turn, source: opportunity.source, + event: `turn-alliance-outcome:${index}`, player: actor, asker: g.players.find(player => player.id !== actor)!.id })); + const plan = alliancePromiseRule(() => findAlliancePromisePlan([...scalars, ...exact], actor, opportunity, + { players: g.players, offers: g.allianceOffers })); + if (plan !== null) { + const route = { steps: plan.map(action => ({ player: actor, target: action.target })), uncertain: false }; + const key = JSON.stringify(route); + if (!seen.has(key)) { seen.add(key); plans.push(route); } + continue; + } + if (!includeUnknown || partner === null || getPlayer(g, actor).ally === partner || + getPlayer(g, partner).ally === null && g.allianceOffers[partner] === actor) continue; + // This detached consent scenario is used only to decide Unknown, never + // Yes or a guide. Native break/offer/accept quotes still validate every + // step; original payments and all other resource checks run below. + let players = g.players.map(player => ({ id: player.id, ally: player.ally })), offers = { ...g.allianceOffers }; + const steps: TurnActionAllianceRoute['steps'] = []; + const add = (player: string, target: string | null) => { + const quote = nexusRule(() => quoteNexusAlliance({ players, offers, actor: player, target })); + players = players.map(seat => ({ ...seat, ally: quote.allies[seat.id] })); offers = quote.offers; + steps.push({ player, target }); + }; + try { + if (players.find(player => player.id === actor)!.ally !== null) add(actor, null); + if (players.find(player => player.id === partner)!.ally !== null) add(partner, null); + add(partner, actor); + if (players.find(player => player.id === actor)!.ally !== partner) add(actor, partner); + const route = { steps, uncertain: true }, key = JSON.stringify(route); + if (!seen.has(key)) { seen.add(key); plans.push(route); } + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + return plans; +} +function turnActionAllianceSnapshot(g: Game, opportunity: AllianceOpportunity): TurnActionPromise[] { + return turnActionRule(() => settleTurnActionAlliance(g.turnActionPromises ?? [], opportunity.turn, + opportunity.source, Object.fromEntries(g.players.map(player => [player.id, player.ally])))); +} +/** Own Ready must retain a completion after membership has become final; + * partner changes are no longer available in that detached closing state. */ +function turnActionAllianceCanClose(g: Game, player: Player): boolean { + const opportunity = allianceOpportunity(g); + if (!opportunity) return false; + if (!alliancePromisesMatch(g.alliancePromises ?? [], player.id, opportunity, player.ally) || + !allianceExpressionPromisesMatch(g.allianceExpressionPromises ?? [], player.id, opportunity, player.ally)) return false; + try { + const closed = turnActionAllianceSnapshot(g, opportunity); + if ((g.turnActionPromises ?? []).some((promise, index) => promise.player === player.id && + promise.turn === g.turn && !promise.fulfilled && !promise.released && closed[index].released)) return false; + const source = { ...g, turnActionPromises: closed, + alliancePromises: (g.alliancePromises ?? []).map(promise => promise.player === player.id && + promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released + ? { ...promise, fulfilled: true } : promise), + allianceExpressionPromises: (g.allianceExpressionPromises ?? []).map(promise => promise.player === player.id && + promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released + ? { ...promise, fulfilled: true } : promise) }; + 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[] }; /** 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): TurnActionCompletion | null { +function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null { const records = [ ...liveTravelPromises(source.travelPromises ?? [], actor, source.turn).map(promise => () => travelRule(() => travelBranches(promise))), @@ -18749,7 +18840,10 @@ function turnActionCompletion(source: Game, actor: string): TurnActionCompletion else if (leaf.kind === 'forces') (trial.revivalPromises ??= []).push({ ...metadata, counter: leaf.counter, minimum: leaf.minimum, progress: leaf.progress }); 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 }); }); + const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent); for (const required of bribePromiseRule(() => bribeCompletionCandidates( trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) { // A quoted payment follows only a currently owned modal continuation; @@ -18763,36 +18857,58 @@ function turnActionCompletion(source: Game, actor: string): TurnActionCompletion if (!Number.isSafeInteger(total) || total > 0 && total > uncommittedSpice(trial, owner) || required.some(payment => payment.recipient === actor || payment.recipient === owner.ally || !trial.players.some(player => player.id === payment.recipient))) continue; - const funded = structuredClone(trial), payer = getPlayer(funded, actor); - try { - for (const payment of required) recordBribePayment(funded, actor, payment.recipient, payment.amount); - payer.spice -= total; - const voluntary = { actor, action: { type: 'bribe' } }; - for (const payment of required) - checkBureaucratContributors(funded, payment.recipient, [{ payer: actor, amount: payment.amount }]); - reconcileBattlePromises(funded, voluntary); - reconcileBidPromises(funded, voluntary); - reconcileRevivalPromises(funded, voluntary); - const lot = bidOpportunity(funded); - const needsBid = !!lot && bidRequired(funded.bidPromises ?? [], actor, lot); - const bid = needsBid ? bidPromiseCompletion(funded, payer) : undefined; - if (needsBid && !bid) continue; - const needsRevival = [ - ...liveRevivalPromises(funded.revivalPromises ?? [], actor, source.turn), - ...liveLeaderRevivalPromises(funded.leaderRevivalPromises ?? [], actor, source.turn), - ...liveRevivalExpressionPromises(funded.revivalExpressionPromises ?? [], actor, source.turn), - ].some(promise => promise.answer); - const revival = needsRevival ? revivalPromisePlan(funded, payer) : undefined; - if (needsRevival && !revival) continue; - const transport = [ - ...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn), - ...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn), - ...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn), - ]; - const completion = transport.length ? groundMovementCompletionNative(funded, actor) : { actions: [] }; - if (completion) return { ...completion, ...(bid ? { bid } : {}), ...(revival ? { revival } : {}), - payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) }; - } catch (error) { if (!(error instanceof RuleError)) throw error; } + for (const alliancePlan of alliancePlans) { + const funded = structuredClone(trial), payer = getPlayer(funded, actor); + try { + for (const payment of required) recordBribePayment(funded, actor, payment.recipient, payment.amount); + payer.spice -= total; + for (const action of alliancePlan?.steps ?? []) { + const quote = normalNexusAllianceQuote(funded, getPlayer(funded, action.player), action.target); + for (const player of funded.players) player.ally = quote.allies[player.id]; + funded.allianceOffers = quote.offers; + if (quote.formed) for (const id of quote.formed) getPlayer(funded, id).allySinceTurn = funded.turn; + funded.ready = []; + } + if (alliancePlan) { + const opportunity = allianceOpportunity(funded)!; + funded.alliancePromises = (funded.alliancePromises ?? []).map(promise => promise.player === actor && + promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released + ? { ...promise, fulfilled: true } : promise); + funded.allianceExpressionPromises = (funded.allianceExpressionPromises ?? []).map(promise => promise.player === actor && + promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released + ? { ...promise, fulfilled: true } : promise); + } + const voluntary = { actor, action: { type: 'bribe' } }; + for (const payment of required) + checkBureaucratContributors(funded, payment.recipient, [{ payer: actor, amount: payment.amount }]); + reconcileBattlePromises(funded, voluntary); + reconcileBidPromises(funded, voluntary); + reconcileRevivalPromises(funded, voluntary); + const lot = bidOpportunity(funded); + const needsBid = !!lot && bidRequired(funded.bidPromises ?? [], actor, lot); + const bid = needsBid ? bidPromiseCompletion(funded, payer) : undefined; + if (needsBid && !bid) continue; + const needsRevival = [ + ...liveRevivalPromises(funded.revivalPromises ?? [], actor, source.turn), + ...liveLeaderRevivalPromises(funded.leaderRevivalPromises ?? [], actor, source.turn), + ...liveRevivalExpressionPromises(funded.revivalExpressionPromises ?? [], actor, source.turn), + ].some(promise => promise.answer); + const revival = needsRevival ? revivalPromisePlan(funded, payer) : undefined; + if (needsRevival && !revival) continue; + const transport = [ + ...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn), + ...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn), + ...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn), + ]; + const completion = transport.length ? groundMovementCompletionNative(funded, actor) : { actions: [] }; + if (completion) { + if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; } + return { ...completion, ...(bid ? { bid } : {}), ...(revival ? { revival } : {}), + ...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}), + payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) }; + } + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } } return null; }; @@ -18803,16 +18919,19 @@ 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; + let bid = false, revival = false, alliance = 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 === 'forces' || node.action.kind === 'leader') revival = true; } else if (node.kind !== 'fact') node.terms.forEach(visit); }; visit(claim.mixed); const lot = bid ? bidOpportunity(g) : null; requireRule(!bid || !!lot, 'Retain the original current ordinary auction lot.'); + const nexus = alliance ? allianceOpportunity(g) : null; + requireRule(!alliance || !!nexus, 'Retain the original current native Nexus.'); const preparation: TurnActionPreparation | undefined = bid || revival ? { bid: lot ? { lot: lot.lot, source: lot.source, maximum: 0, closed: false } : null, revival: revival ? { total: 0, normal: 0, elite: 0, leaders: [], closed: g.phase > 4 || @@ -18820,32 +18939,34 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b } : undefined; return { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled, ...(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 }; } -function turnActionAnswerSource(g: Game, player: string): Game { - return g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 || +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 || g.phase === 7 && bribesCloseBeforeNextMentat(g) ? { ...g, ready: g.ready.filter(id => id !== player) } : g; } function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] { if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return []; - const answers: TruthAnswer[] = []; + 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); if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue; - const trial = { ...turnActionAnswerSource(g, p.id), + const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance), turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined }; - if (turnActionCompletion(trial, p.id)) answers.push(answer ? 'yes' : 'no'); + if (turnActionCompletion(trial, p.id, answer && promise.alliance ? consent : undefined)) answers.push(answer ? 'yes' : 'no'); } + if (!answers.includes('yes') && consent.value) answers.push('unknown'); return answers.length ? answers : ['unknown']; } function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer: boolean, asker: string) { requireRule(turnActionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), 'Choose a whole turn-action answer compatible with current funding and native actions.'); const promise = turnActionPromise(g, p, claim, answer, crypto.randomUUID(), asker); - g.ready = turnActionAnswerSource(g, p.id).ready; + g.ready = turnActionAnswerSource(g, p.id, !!promise.alliance).ready; g.turnActionPromises ??= []; initializeTurnActionOrder(g); g.turnActionPromises.push(promise); @@ -18881,6 +19002,10 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s if (promise.answer) promise.released = true; else promise.fulfilled = true; } if (!bribePromiseTurn(g) || g.truthtrance) return; + const nexus = allianceOpportunity(g); + if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise => + promise.player === actor && promise.turn === g.turn && promise.alliance?.source === nexus.source)) + g.ready = g.ready.filter(player => player !== actor); initializeTurnActionOrder(g); for (const p of g.players) { const early = g.phase < 5 || sourcePhase < 5; @@ -18912,6 +19037,9 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s promise.released = true; log(g, `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`); } + if (nexus && g.ready.includes(p.id) && liveTurnActionPromises(g.turnActionPromises!, p.id, g.turn) + .some(promise => promise.alliance?.source === nexus.source && !promise.alliance.closed) && + !turnActionAllianceCanClose(g, p)) g.ready = g.ready.filter(player => player !== p.id); } } function projectedTurnActionPromises(g: Game, player: string) { @@ -18955,6 +19083,8 @@ 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 === 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' }; if (!action) return null; const packet = { ...structuredClone(action) }; diff --git a/game/reference.ts b/game/reference.ts index 6fb69c29..d17db667 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3187,11 +3187,14 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { id: 'truthtrance-turn-actions', - title: 'Truthtrance: combined bids, Revival, payments and transport', + title: 'Truthtrance: combined alliances, bids, Revival, payments and transport', category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Combine ordinary current-lot bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.', + 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.', steps: [ - 'Choose Combine this turn’s 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 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.', + '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.', 'Only actual ordinary bribes paid by the respondent after answering add payment progress. Payments to the same recipient accumulate. Shipment fees, gifts and later receipt of incoming bribes do not count. Native recipient restrictions, costs and receipt timing remain.', '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.', @@ -3201,13 +3204,13 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', 'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.', ], - related: ['card-truthtrance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'], + 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 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 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'] }, + { 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: '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'] }, + { 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'] }, ], }, { diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 3adf5427..e9c7861b 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -392,6 +392,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { if (action.kind === 'leader') check(leaders(respondent.faction).some(leader => leader.id === action.leader) || action.leader === 'kwisatz' && g.advanced && respondent.faction === 'atreides', 'Choose a personal native faction leader, or the Advanced Atreides Kwisatz Haderach.'); + if (action.kind === 'alliance') check(alliancePromiseModeSupported(g) && g.phase === 1 && g.nexus && + !g.spiceWindow && !g.spiceResolution && action.partner !== respondent.id && + g.players.some(player => player.id === action.partner), + 'Choose another seated final alliance partner in the current quiet native Nexus.'); return action; }, { requireFact: false, maxDepth: TURN_ACTION_MAX_DEPTH, maxLeaves: TURN_ACTION_MAX_LEAVES }); return { kind: 'turnActions', target: v.target as string, turn: g.turn, mixed }; @@ -709,6 +713,7 @@ 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 === '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`; return `you ${action.kind === 'ship' ? shipmentClaimText(action) : movementClaimText(action)} before your Shipment and Movement turn ends`; diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts index f1476139..8de61a76 100644 --- a/game/turn-action-promises.ts +++ b/game/turn-action-promises.ts @@ -1,6 +1,7 @@ import { TERRITORIES } from './board'; import { BribePromiseError, parseBribeClaim } from './bribe-promises'; 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 { @@ -22,19 +23,21 @@ 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: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string }; export type TurnActionClaim = { turn: number; mixed: MixedActionExpression }; export type TurnActionPayment = { recipient: string; amount: number }; export type TurnActionBranchLeaf = TravelBranchLeaf | { kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean } | { 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: 'leader'; leader: string; answer: boolean } | + { kind: 'alliance'; partner: string; source: string; answer: boolean }; 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 TurnActionPromise = TurnActionClaim & { event: string; player: string; @@ -53,6 +56,8 @@ export type TurnActionPromise = TurnActionClaim & { released: boolean; /** Optional only for original shipment/movement/payment records. */ preparation?: TurnActionPreparation; + /** Final membership is recorded only by the original Nexus closing event. */ + alliance?: TurnActionAlliance; }; export class TurnActionError extends Error { @@ -81,8 +86,13 @@ const printedTerritory = (value: unknown): value is string => /** 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, reserve shipment, ground movement or bribe payment.'); + check(object(value), 'Choose one future ordinary bid, Revival, final Nexus alliance, reserve shipment, ground movement or bribe payment.'); try { + if (value.kind === 'alliance') { + check(keys(value, 'kind,partner'), 'Supply only one final Nexus alliance partner.'); + const claim = parseAllianceClaim({ turn: 1, partner: value.partner }); + return { kind: 'alliance', partner: claim.partner }; + } if (value.kind === 'bid') { check(keys(value, 'kind,lot,minimum'), 'Supply only the current ordinary lot and positive bid minimum.'); const claim = parseBidClaim({ turn: 1, lot: value.lot, minimum: value.minimum }); @@ -95,7 +105,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf { return { kind: 'payment', recipient: claim.recipient, minimum: claim.minimum }; } catch (error) { if (error instanceof TravelPromiseError || error instanceof BribePromiseError || error instanceof BidPromiseError || - error instanceof RevivalExpressionPromiseError) + error instanceof RevivalExpressionPromiseError || error instanceof AlliancePromiseError) throw new TurnActionError(error.message); throw error; } @@ -116,6 +126,7 @@ function storedFact(value: unknown, depth = 0): TruthFact { } function actionKey(action: TurnActionLeaf): string { + 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]); if (action.kind === 'bid') return JSON.stringify([action.kind, action.lot, action.minimum]); @@ -155,9 +166,11 @@ function validateEvent(event: unknown): asserts event is TravelEvent { } function readPromise(promise: TurnActionPromise): CompiledMixedAction { - const fields = 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn'; - check(object(promise) && (keys(promise, fields) || keys(promise, - 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,preparation,progress,released,shipment,transportClosed,turn')) && + check(object(promise), 'Retain the complete original turn-action commitment and its metadata.'); + 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'); + 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.'); let mixed: MixedActionExpression; @@ -174,6 +187,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction(); const lots = new Set(); let hasRevival = false; + let hasAlliance = false; const visit = (node: MixedActionExpression): void => { if (node.kind === 'action') { originals.add(actionKey(node.action)); @@ -181,11 +195,20 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction= action.minimum; if (action.kind === 'forces') return (promise.preparation?.revival?.[action.counter] ?? 0) >= action.minimum; if (action.kind === 'leader') return promise.preparation?.revival?.leaders.includes(action.leader) ?? false; @@ -252,6 +276,7 @@ function evaluate(node: CompiledMixedAction, promise: TurnAction } function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean { + 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; return action.kind === 'payment' ? promise.paymentClosed : action.kind === 'ship' @@ -289,6 +314,7 @@ export function liveTurnActionPromises(promises: re function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean { 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 === '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; @@ -299,18 +325,22 @@ 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') { + if (leaf.kind === 'leader' || leaf.kind === 'alliance') { 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' && + other.answer && other.source === leaf.source && other.partner !== leaf.partner)) return null; if (!prior) merged.push({ ...leaf }); continue; } const same = merged.find(other => sameAction(other, leaf) && other.answer === leaf.answer); - if (same && same.kind !== 'leader') same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum); + if (same && same.kind !== 'leader' && same.kind !== 'alliance') + 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.minimum >= no.minimum) return null; + if (yes && no && yes.kind !== 'leader' && no.kind !== 'leader' && yes.kind !== 'alliance' && no.kind !== 'alliance' && + 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; } @@ -328,6 +358,7 @@ export function* turnActionBranches(promise: TurnActionPromise): Generator payment.recipient === action.recipient)?.amount ?? 0 }]; @@ -360,6 +391,29 @@ function settle(promise: T, update: T): T { ? { ...update, fulfilled: true } : update; } +/** 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. */ +export function settleTurnActionAlliance(promises: readonly T[], turn: number, + source: string, allies: Readonly>): T[] { + check(Array.isArray(promises) && positive(turn) && identifier(source) && object(allies) && + Object.entries(allies).every(([player, partner]) => identifier(player) && + (partner === null || identifier(partner) && partner !== player)), + 'Retain the original Nexus and its actual final alliance snapshot.'); + 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 stage = promise.alliance; + if (!stage || stage.closed || stage.source !== source) return promise; + check(Object.prototype.hasOwnProperty.call(allies, promise.player), + 'Retain the respondent’s actual final membership at this original Nexus closure.'); + const update = { ...promise, alliance: { ...stage, partner: allies[promise.player], closed: true } }; + if (!promise.answer && turnActionValue(update)) return { ...update, released: true }; + return settle(promise, update); + }); +} + /** Only an actually accepted ordinary declaration counts for its original lot. * Repeated declarations retain their largest amount, never their sum or invoice. */ export function recordTurnActionBid(promises: readonly T[], player: string, @@ -515,7 +569,7 @@ export function finishTurnActionTurn(promises: read bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null, revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null, } } : {}) }; - const fulfilled = turnActionValue(closed) === promise.answer; + const fulfilled = !(promise.alliance && !promise.alliance.closed) && turnActionValue(closed) === promise.answer; return { ...closed, fulfilled, released: !fulfilled }; }); } @@ -530,12 +584,15 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[ const satisfied = evaluate(compiled, promise); const allClosed = promise.transportClosed && promise.paymentClosed && (!promise.preparation?.bid || promise.preparation.bid.closed) && - (!promise.preparation?.revival || promise.preparation.revival.closed); + (!promise.preparation?.revival || promise.preparation.revival.closed) && + (!promise.alliance || promise.alliance.closed); const recipientsValid = (node: MixedActionExpression): boolean => node.kind === 'action' - ? node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player + ? 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 : node.kind === 'fact' || node.terms.every(recipientsValid); 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.released || (promise.answer || !satisfied) && (!promise.answer || promise.fulfilled === satisfied) && (!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) && diff --git a/tests/fixture-truth-turn-alliance.ts b/tests/fixture-truth-turn-alliance.ts new file mode 100644 index 00000000..b18f939c --- /dev/null +++ b/tests/fixture-truth-turn-alliance.ts @@ -0,0 +1,136 @@ +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 { TruthFact, TruthQuestion } from '../game/truthtrance'; +import { nextTruthBid, type TruthBidStep } from './fixture-truth-bid'; +import { createTruthMixedAllianceFixture } from './fixture-truth-alliance-expression'; +import { truthAllianceNexus } from './fixture-truth-alliance'; +import { + actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer, +} from './fixture-truth-turn-action'; +import type { TruthTurnPreparationLeaf } from './fixture-truth-turn-preparation'; + +export type TruthTurnAllianceLeaf = TruthTurnPreparationLeaf | { kind: 'alliance'; partner: string }; +export type TruthTurnAllianceExpression = MixedActionExpression; +export type TruthTurnAllianceStep = TruthBidStep; +export const actTruthTurnAlliance = actTruthTurnAction; +export const truthTurnAllianceInventory = truthTurnActionInventory; +export const truthTurnAlliancePlayer = truthTurnActionPlayer; +export const truthTurnAllianceNexus = truthAllianceNexus; +export const truthTurnAllianceClean = (game: Game) => !game.truthtrance && !game.phaseOpening && + !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival; +export const truthTurnAllianceFact = (fact: TruthFact): TruthTurnAllianceExpression => ({ kind: 'fact', fact }); +export const truthTurnAlliancePartner = (partner: string): TruthTurnAllianceExpression => + ({ kind: 'action', action: { kind: 'alliance', partner } }); +export const truthTurnAlliancePayment = (recipient: string, minimum = 2): TruthTurnAllianceExpression => + ({ kind: 'action', action: { kind: 'payment', recipient, minimum } }); +export const truthTurnAllianceShip = (territory = 'polar_sink', minimum = 1): TruthTurnAllianceExpression => + ({ kind: 'action', action: { kind: 'ship', territory, minimum } }); +export const truthTurnAllianceGroup = (kind: 'and' | 'or', ...terms: TruthTurnAllianceExpression[]): TruthTurnAllianceExpression => + ({ kind, terms }); + +/** Reuse the original H/A/E base33, two actual opening Truths, real first turn, + * later Worm and native reciprocal invitation/acceptance. Only the raw question + * changes; payment names the third seat so forming this pair leaves a legal + * ordinary payment recipient. No played state or private progress is set. */ +export function createTruthTurnAllianceFixture(options: { + advanced?: boolean; consent?: 'none' | 'offered' | 'allied'; secondOffer?: boolean; + mixed?: TruthTurnAllianceExpression; +} = {}) { + const { mixed: suppliedMixed, ...sourceOptions } = options; + const base = createTruthMixedAllianceFixture(sourceOptions), game = structuredClone(base.game); + const asker = base.asker, target = base.target, partner = base.partner; + const recipient = game.players.find(candidate => candidate.id !== target && candidate.id !== partner)!.id; + const mixed = structuredClone(suppliedMixed ?? truthTurnAllianceGroup('and', truthTurnAlliancePartner(partner), + truthTurnAlliancePayment(recipient), truthTurnAllianceShip())); + const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion; + const actions = structuredClone(base.sourceActions); + const falseSpiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 11 }; + assert.ok(truthTurnAllianceNexus(game)); assert.equal(truthTurnAlliancePlayer(game, target).spice, 10); + assert.ok(base.truths.every(truth => truthTurnAlliancePlayer(game, asker).hand.some(card => card.id === truth))); + assert.equal(game.turnActionPromises?.length ?? 0, 0); + assert.deepEqual(truthTurnAllianceInventory(game), base.originalCards); + return { + game, source: structuredClone(game), base, asker, target, partner, recipient, + truths: structuredClone(base.truths), truth: base.truths[0], mixed, question, + initial: base.initial, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal, afterSetup: base.afterSetup, + originalCards: base.originalCards, originalSpice: base.originalSpice, staging: base.staging, + actions, sourceActions: actions, sourceActionCount: actions.length, + sourceCounts: { inherited: base.sourceCounts.inherited, additionalInvitations: base.sourceCounts.additionalInvitations, + selected: actions.length }, + spiceFact: base.spiceFact, falseSpiceFact, shieldFact: base.shieldFact, hajrFact: base.hajrFact, + allianceAction: { type: 'alliance', target: partner } satisfies Action, + breakAction: { type: 'alliance', target: null } satisfies Action, + payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action, + shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action, + sourceDisclosure: `${base.sourceDisclosure} This adapter changes only the raw combined turn-action question. ` + + 'The native ordinary Nexus closing event alone freezes final membership. A proposal, temporary pair or own Ready is not that event. ' + + 'All source counts derive from original recorded arrays; no hand, card, cash, force, offer, alliance, clock, promise or receipt is installed.', + }; +} +export type TruthTurnAllianceFixture = ReturnType; + +export function declareTruthTurnAlliance(f: TruthTurnAllianceFixture, source = f.game, copy: 0 | 1 = 0, + actions: TruthTurnAllianceStep[] = []) { + const before = structuredClone(source), from = actions.length; + let game = actTruthTurnAlliance(source, f.asker, { type: 'card', card: f.truths[copy] }, actions); + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor); + game = actTruthTurnAlliance(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return { game, before, actions, actionCount: actions.length - from }; +} +export function askTruthTurnAlliance(game: Game, asker: string, target: string, mixed: TruthTurnAllianceExpression, + actions: TruthTurnAllianceStep[] = []): Game { + return actTruthTurnAlliance(game, asker, { type: 'truthAsk', question: { + kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed), + } }, actions); +} +export function answerTruthTurnAlliance(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', + actions: TruthTurnAllianceStep[] = []): Game { + return actTruthTurnAlliance(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthTurnAlliance(f: TruthTurnAllianceFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed, + source = f.game, copy: 0 | 1 = 0, actions: TruthTurnAllianceStep[] = []) { + const from = actions.length, declared = declareTruthTurnAlliance(f, source, copy, actions); + const asked = askTruthTurnAlliance(declared.game, f.asker, f.target, mixed, actions); + const game = answerTruthTurnAlliance(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length - from }; +} +export function truthTurnAlliancePromise(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 nextTruthTurnAllianceNative(game: Game): TruthTurnAllianceStep | null { + return nextTruthBid(game); +} +export function nextTruthTurnAlliance(game: Game): TruthTurnAllianceStep | 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 nextTruthTurnAllianceNative(game); +} +export function advanceTruthTurnAlliance(game: Game, until: (state: Game) => boolean, + actions: TruthTurnAllianceStep[] = [], nativeOnly = false): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original alliance source ended before its requested native boundary'); + const next = nativeOnly ? nextTruthTurnAllianceNative(game) : nextTruthTurnAlliance(game); + assert.ok(next, 'Supply the original Truthtrance question, answer or saved-card decision'); + game = actTruthTurnAlliance(game, next.actor, next.action, actions); + } + throw Error('The genuine combined alliance source did not reach its finite boundary'); +} +export function settleTruthTurnAlliance(game: Game, actions: TruthTurnAllianceStep[] = []): Game { + return advanceTruthTurnAlliance(game, truthTurnAllianceClean, actions); +} +export function closeTruthTurnAllianceNexus(game: Game, actions: TruthTurnAllianceStep[] = [], nativeOnly = false): Game { + assert.ok(truthTurnAllianceNexus(game)); const turn = game.turn; + return advanceTruthTurnAlliance(game, state => state.turn === turn && !state.nexus && truthTurnAllianceClean(state), actions, nativeOnly); +} +export function finishTruthTurnAlliance(game: Game, actions: TruthTurnAllianceStep[] = []): Game { + const turn = game.turn; + return advanceTruthTurnAlliance(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnAllianceClean(state), actions); +} diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx index 1c859a51..ddaa6c22 100644 --- a/tests/truth-turn-action-controls.test.tsx +++ b/tests/truth-turn-action-controls.test.tsx @@ -25,6 +25,7 @@ const bid = (minimum = 3, lot = 2): Expression => ({ kind: 'action', action: { k const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum = 1): Expression => ({ 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 claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] }); function elements(node: ReactNode): Element[] { @@ -75,6 +76,14 @@ function auction(next: Action = { type: 'passBid' }): GameView { if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; } return game; } +function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView { + const game = projected(1, next); + game.nexus = true; game.spiceWindow = null; game.active = 'a'; + game.alliancePromiseNexus = { turn: 2 }; game.truthMixedAllianceTargets = ['p', 'a', 'r']; + game.allianceOffers = { r: 'p' }; + game.turnActionPromises[0].mixed = { kind: 'or', terms: [finalAlliance(), 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: [] }); @@ -251,6 +260,120 @@ void test('turn-action preparation history and guides use original public leaves assert.match(answerMarkup, /ordinary force returns add together/); assert.match(answerMarkup, /Bids close with their lot/); }); +void test('turn-action alliance fields offer every public other partner including the current ally only in the open native Nexus', () => { + const game = nexus(); game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p'; + for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'leaders', 'tanks']) + Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + Object.defineProperty(game.turnActionPromises[0], 'alliance', { get() { throw Error('No private alliance binding'); } }); + const e = editor(physical(), false, game); + select(e.tree(), '1', 'action', 'alliance'); assert.deepEqual(e.value, finalAlliance('')); assert.ok(e.error()); + const options = elements(find(e.tree(), 'id', 'truth-turn-action-1-partner')).filter(element => element.type === 'option'); + assert.deepEqual(options.map(option => option.props.value), ['', 'a', 'r']); + select(e.tree(), '1', 'partner', 'r'); assert.equal(e.error(), null); assert.deepEqual(e.value, finalAlliance()); + select(e.tree(), '1', 'partner', 'p'); select(e.tree(), '1', 'partner', 'missing'); assert.deepEqual(e.value, finalAlliance()); + for (const partner of ['', 'p', 'missing', ' r ']) assert.ok(turnActionInputError(finalAlliance(partner), game, 'p')); + select(e.tree(), '1', 'partner', ''); assert.ok(e.error()); select(e.tree(), '1', 'partner', 'a'); assert.equal(e.error(), null); + assert.match(e.html(), /actual final membership when this current Nexus closes/); + assert.match(e.html(), /No can allow temporary membership followed by a break/); + const frozen = editor(finalAlliance(), true, game); select(frozen.tree(), '1', 'partner', 'a'); select(frozen.tree(), '1', 'action', 'payment'); + assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, finalAlliance()); + for (const change of [ + (current: GameView) => { current.status = 'setup'; }, (current: GameView) => { current.phase = 2; }, + (current: GameView) => { current.nexus = false; }, + (current: GameView) => { current.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; }, + (current: GameView) => { current.alliancePromiseNexus = null; }, (current: GameView) => { current.alliancePromiseNexus!.turn = 1; }, + (current: GameView) => { current.truthMixedAllianceTargets = []; }, + (current: GameView) => { current.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, + (current: GameView) => { current.decision = { kind: 'auctionPayment', player: 'p' }; }, + (current: GameView) => { current.phaseOpening = { passed: [] }; }, + (current: GameView) => { current.automaticContinuationPending = true; }, + ]) { + const current = nexus(); change(current); const invalid = editor(finalAlliance(), false, current); + assert.ok(invalid.error()); assert.match(invalid.html(), /Alliance conditions require the current open Nexus/); + } +}); + +void test('turn-action alliance leaves retain the complete sixteen-clause and four-level optional-fact expression', () => { + const game = nexus(), e = editor({ kind: 'or', terms: [finalAlliance(), fact()] }, false, game); + for (let count = 3; count <= 16; count++) { + click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'action', 'alliance'); + select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'a' : 'r'); + } + assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/); + const full = structuredClone(e.value), changes = e.changes; + for (const key of ['add-action-1', 'add-fact-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) { + assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key)); + } + assert.deepEqual(e.value, full); assert.equal(e.changes, changes); + const deep = editor({ kind: 'and', terms: [fact(), finalAlliance()] }, false, game); + 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.match(deep.html(), /4 \/ 4 levels of grouping/); assert.equal(deep.error(), null); + assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true); +}); + +void test('turn-action Nexus guide forwards only exact breaks, withdrawals, reciprocal acceptances and Ready without private alliance reads', () => { + for (const advanced of [false, true]) for (const kind of ['accept', 'break', 'withdraw', 'ready'] as const) { + const game = nexus(kind === 'ready' ? { type: 'ready' } : { type: 'alliance', target: kind === 'accept' ? 'r' : null }); + game.advanced = advanced; + if (kind === 'break') { game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p'; } + if (kind === 'withdraw') game.allianceOffers = { p: 'r' }; + if (kind === 'ready') { + game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p'; + game.turnActionPromises[0].mixed = { kind: 'and', terms: [finalAlliance(), payment()] }; game.turnActionPromises[0].answer = false; + } + for (const key of ['alliance', 'compiled', 'preparation', 'progress', 'payments']) + Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves']) + Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } }); + assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action); + assert.equal(game.turnActionPromises[0].fulfilled, false, 'A guide dispatch does not announce final Nexus fulfillment.'); + const html = markup(game); + assert.match(html, kind === 'accept' ? /Accept Recipient’s alliance invitation/ : kind === 'break' ? /Break your current alliance/ + : kind === 'withdraw' ? /Withdraw your alliance offer/ : /Confirm Ready for this Nexus/); + assert.match(html, /forming an alliance does not fulfill it early/); + assert.doesNotMatch(html, /Fulfilled: this whole commitment is complete/); + } + const invalid = [nexus(), nexus(), nexus(), nexus()]; + invalid[0].allianceOffers = {}; + invalid[1].turnActionGuide!.action.target = 'p'; + invalid[2].turnActionGuide!.action.target = 'missing'; + invalid[3].players.find(player => player.id === 'r')!.ally = 'a'; + for (const game of invalid) { + const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } }); + assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []); + } +}); + +void test('turn-action Nexus callbacks expire with public consent, membership, readiness, source and quiet-window changes', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.nexus = false; }, game => { game.phase = 2; }, game => { game.turn = 3; }, game => { game.me = 'a'; }, + game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; }, + game => { game.alliancePromiseNexus = null; }, game => { game.alliancePromiseNexus = { turn: 2 }; }, + game => { game.alliancePromiseNexus!.turn = 1; }, game => { game.allianceOffers = {}; }, game => { game.allianceOffers.a = 'p'; }, + game => { game.ready.push('p'); }, game => { game.ready.push('a'); }, + game => { game.players.find(player => player.id === 'p')!.ally = 'a'; }, game => { game.players.find(player => player.id === 'a')!.ally = 'r'; }, + game => { game.players = game.players.filter(player => player.id !== 'r'); }, + game => { game.turnActionGuide = { ...game.turnActionGuide! }; }, game => { game.turnActionGuide!.action = { ...game.turnActionGuide!.action }; }, + game => { game.turnActionGuide!.action.turnActionPromisePhase = 2; }, game => { game.turnActionGuide!.action.turnActionPromiseEvent = 'stale'; }, + game => { game.turnActionPromises[0].event = 'stale'; }, game => { game.turnActionPromises[0].fulfilled = true; }, + game => { game.truthtrance = answering(1).truthtrance; }, game => { game.phaseOpening = { passed: [] }; }, + game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, + game => { game.automaticContinuationPending = true; }, game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; }, + ]; + for (const action of [{ type: 'alliance', target: 'r' }, { type: 'ready' }] satisfies Action[]) for (const change of changes) { + const game = nexus(action), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } }); + assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []); + } + const busy = { game: nexus(), busy: false, act() { assert.fail('Busy Nexus guide cannot send'); } }; + const button = guide(busy); busy.busy = true; press(button); + const replaced = { game: nexus(), busy: false, act() { assert.fail('Replaced Nexus view cannot send'); } }; + const old = guide(replaced); replaced.game = { ...replaced.game }; press(old); + const source = nexus({ type: 'ready' }); source.nexus = false; + const retained = guide({ game: source, busy: false, act() { assert.fail('A closed rendered Nexus cannot become a fresh opportunity'); } }); + source.nexus = true; press(retained); +}); + 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( @@ -259,7 +382,7 @@ void test('turn-action history retains original own whole questions and terminal { ...game.turnActionPromises[0], event: 'controlled-release', released: true }, { ...game.turnActionPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), physical('ship', 19, 'sietch_tabr')] } }, ); - for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress', 'preparation', 'prep', 'bids', 'returns', 'leaders']) + for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress', 'preparation', 'prep', 'bids', 'returns', 'leaders', 'alliance']) Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); const html = markup(game); for (const text of [/Turn 1/, /You answered Yes/, /You answered No/, /you hold Shield/, / OR /, / AND /, @@ -314,7 +437,7 @@ void test('turn-action ordinary-payment guide retains native out-of-turn timing assert.match(markup(game), /Ready to end this turn while honoring your whole answer/); game.ready.push('p'); press(button); assert.equal(sent.length, 1); for (const phase of [1, 7]) { - const current = projected(phase, { type: 'ready' }); current.nexus = true; current.spiceWindow = null; + const current = phase === 1 ? nexus({ type: 'ready' }) : projected(phase, { type: 'ready' }); const dispatched: Action[] = [], ready = guide({ game: current, busy: false, act(value) { dispatched.push(value); } }); assert.equal(ready.props.disabled, false); press(ready); assert.equal(dispatched[0], current.turnActionGuide!.action); if (phase === 1) { current.nexus = false; press(ready); assert.equal(dispatched.length, 1); } diff --git a/tests/truth-turn-alliance-runtime.test.ts b/tests/truth-turn-alliance-runtime.test.ts new file mode 100644 index 00000000..af2b331d --- /dev/null +++ b/tests/truth-turn-alliance-runtime.test.ts @@ -0,0 +1,275 @@ +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 { + actTruthTurnAlliance as act, advanceTruthTurnAlliance as advance, answerTruthTurnAlliance as answer, + askTruthTurnAlliance as ask, bindTruthTurnAlliance as bind, closeTruthTurnAllianceNexus as close, + createTruthTurnAllianceFixture as fixture, declareTruthTurnAlliance as declare, + finishTruthTurnAlliance as finish, nextTruthTurnAllianceNative as nativeStep, + settleTruthTurnAlliance as settle, truthTurnAllianceClean as clean, truthTurnAllianceFact as fact, + truthTurnAllianceGroup as group, truthTurnAllianceInventory as inventory, truthTurnAllianceNexus as nexus, + truthTurnAlliancePartner as partner, truthTurnAlliancePayment as payment, truthTurnAlliancePlayer as player, + truthTurnAlliancePromise as promise, truthTurnAllianceShip as ship, type TruthTurnAllianceFixture, type TruthTurnAllianceStep, +} from './fixture-truth-turn-alliance'; + +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: TruthTurnAllianceFixture) { + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); + assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1])); + assert.deepEqual(inventory(game), f.originalCards); +} + +void test('native combined alliance sources retain the original Worm, actual invitations and two physically dealt Truths', () => { + for (const secondOffer of [false, true]) { + const f = fixture({ secondOffer }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0)); + assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2); + assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths); + assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths); + assert.ok(nexus(f.game)); assert.equal(f.game.turn, 2); assert.deepEqual(f.staging, []); + assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length); + assert.equal(f.sourceCounts.selected, f.sourceActionCount); + assert.equal(f.sourceCounts.inherited + Number(secondOffer), f.sourceActionCount); + assert.equal(f.game.allianceOffers[f.partner], f.target); + if (secondOffer) assert.equal(f.game.allianceOffers[f.asker], f.target); + assert.equal(player(f.game, f.target).ally, null); assert.equal(player(f.game, f.target).spice, 10); + assert.equal(f.target, 'emperor'); assert.equal(f.partner, 'atreides'); assert.equal(f.recipient, 'harkonnen'); + assert.equal(player(f.game, f.target).reserves, 20); assert.deepEqual(player(f.game, f.target).forces, {}); + assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.deepEqual(inventory(f.game), f.originalCards); + } +}); + +void test('native AND combines final original Nexus membership, a real third-seat payment and later paid reserve shipment', () => { + const f = fixture(), actions: TruthTurnAllianceStep[] = []; + let game = bind(f, 'yes', f.mixed, f.game, 0, actions).game; + rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment, actions); + assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.recipient).bribes, 2); + game = act(game, f.target, f.allianceAction, actions); + assert.equal(player(game, f.target).ally, f.partner); assert.equal(player(game, f.partner).ally, f.target); + assert.equal(game.turnActionPromises![0].alliance?.closed, false); + assert.equal(game.turnActionPromises![0].alliance?.partner, null); + assert.ok(!promise(game, f.target).fulfilled); + game = act(game, f.target, { type: 'ready' }, actions); assert.ok(game.ready.includes(f.target)); + assert.equal(game.turnActionPromises![0].alliance?.closed, false, 'Own Ready is not the original Nexus closing event'); + game = close(game, actions); + assert.equal(game.turnActionPromises![0].alliance?.closed, true); + assert.equal(game.turnActionPromises![0].alliance?.partner, f.partner); + assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released); + game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions); + rejected(game, f.target, { type: 'endMovement' }); + game = settle(act(game, f.target, f.shipment, actions), actions); + assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 19); + assert.equal(player(game, f.target).forces['polar_sink:0'], 1); + assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); +}); + +void test('native final-partner OR payment retains both manual routes without counting temporary membership', () => { + const f = fixture(), mixed = group('or', partner(f.partner), payment(f.recipient)), bound = bind(f, 'yes', mixed).game; + let allied = act(bound, f.target, f.allianceAction); + assert.ok(!promise(allied, f.target).fulfilled); assert.equal(gameAlliance(allied), null); + allied = close(allied); assert.equal(promise(allied, f.target).fulfilled, true); + assert.equal(player(allied, f.target).ally, f.partner); assert.equal(player(allied, f.recipient).bribes, 0); + let paid = act(bound, f.target, f.payment); + assert.equal(promise(paid, f.target).fulfilled, true); assert.equal(player(paid, f.target).ally, null); + paid = close(paid); assert.equal(player(paid, f.target).ally, null); + for (const state of [allied, paid]) { assert.deepEqual(promise(state, f.target).mixed, mixed); physical(state, f); } +}); +function gameAlliance(game: Game) { return game.turnActionPromises![0].alliance?.partner; } + +void test('native whole AND No permits an actually paid temporary pair and its later break before final closure', () => { + const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient)); + let game = bind(f, 'no', mixed).game; + game = act(game, f.target, f.payment); game = act(game, f.target, f.allianceAction); + assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).fulfilled); + assert.equal(game.turnActionPromises![0].alliance?.closed, false); rejected(game, f.target, { type: 'ready' }); + game = act(game, f.target, f.breakAction); game = close(game); + assert.equal(game.turnActionPromises![0].alliance?.closed, true); + assert.equal(game.turnActionPromises![0].alliance?.partner, null); assert.ok(!promise(game, f.target).released); + game = advance(game, state => state.phase === 8 && clean(state), [], true); + game = act(game, f.target, { ...f.payment, amount: 3 }); + assert.ok(!promise(game, f.target).released); game = finish(game); + assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); +}); + +void test('native final unpaired Nexus outcome leaves a payment OR available through actual late Mentat', () => { + const f = fixture(), mixed = group('or', partner(f.partner), payment(f.recipient)); + let game = bind(f, 'yes', mixed).game; game = close(game, [], true); + assert.equal(player(game, f.target).ally, null); + assert.equal(game.turnActionPromises![0].alliance?.closed, true); + assert.equal(game.turnActionPromises![0].alliance?.partner, null); + assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released); + 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); physical(game, f); +}); + +for (const consent of ['offered', 'allied'] as const) + void test(`native foreign ${consent === 'offered' ? 'invitation withdrawal' : 'actual departure'} releases a required final partnership without inventing renewed consent`, () => { + const f = fixture({ consent }); let game = bind(f).game; + game = act(game, f.partner, { type: 'alliance', target: null }); + assert.equal(player(game, f.target).ally, null); assert.equal(promise(game, f.target).released, true); + assert.ok(!promise(game, f.target).fulfilled); + const released = structuredClone(game.turnActionPromises![0]); + game = act(game, f.partner, { type: 'alliance', target: f.target }); + assert.equal(promise(game, f.target).released, true); assert.deepEqual(game.turnActionPromises![0], released); + game = act(game, f.target, f.allianceAction); game = close(game); + assert.equal(promise(game, f.target).released, true); assert.ok(!promise(game, f.target).fulfilled); physical(game, f); + }); + +void test('native foreign invitation withdrawal preserves a funded payment OR and never supplies an assumed alliance', () => { + const f = fixture(), mixed = group('or', partner(f.partner), payment(f.recipient)); + let game = bind(f, 'yes', mixed).game; + game = act(game, f.partner, { type: 'alliance', target: null }); + assert.equal(player(game, f.target).ally, null); assert.ok(!promise(game, f.target).released); + game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); + game = close(game); assert.equal(player(game, f.target).ally, null); physical(game, f); +}); + +void test('native missing reciprocal consent never permits a definite combined final-partner Yes or consumes the card at rejected answer', () => { + const f = fixture({ consent: 'none' }), asked = ask(declare(f).game, f.asker, f.target, f.mixed); + assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no', 'unknown']); + rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + assert.deepEqual(asked.allianceOffers, {}); assert.equal(player(asked, f.target).ally, null); + assert.equal(asked.turnActionPromises?.length ?? 0, 0); + assert.equal(asked.discard.filter(card => card.id === f.truth).length, 0); + assert.deepEqual(player(asked, f.asker).hand.map(card => card.id), f.truths); + assert.deepEqual(inventory(asked), f.originalCards); + let game = answer(asked, f.target, 'unknown'); assert.equal(game.truthtrance?.stage, 'unknown'); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 0); + game = act(game, f.asker, { type: 'truthSave' }); assert.equal(game.truthtrance, null); + assert.deepEqual(player(game, f.asker).hand.map(card => card.id), f.truths); + assert.equal(game.turnActionPromises?.length ?? 0, 0); assert.equal(player(game, f.target).ally, null); + assert.deepEqual(game.allianceOffers, {}); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native known unaffordable payment with existing real consent offers No rather than treating finance as unknown consent', () => { + const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 11)); + const asked = ask(declare(f).game, f.asker, f.target, mixed); + assert.equal(asked.allianceOffers[f.partner], f.target); assert.equal(player(asked, f.target).spice, 10); + assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); + rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + const game = answer(asked, f.target, 'no'); assert.equal(promise(game, f.target).answer, false); physical(game, f); +}); + +void test('native missing consent cannot conceal a known unaffordable whole payment branch as Unknown', () => { + const f = fixture({ consent: 'none' }), mixed = group('and', partner(f.partner), payment(f.recipient, 11)); + const game = ask(declare(f).game, f.asker, f.target, mixed); + assert.equal(player(game, f.target).spice, 10); + assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'unknown' }); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + assert.ok(player(game, f.asker).hand.some(card => card.id === f.truth)); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native payment after own Nexus Ready uniformly reopens readiness and preserves a required later break', () => { + const f = fixture({ consent: 'allied' }), mixed = group('and', partner(f.partner), payment(f.recipient)); + let game = bind(f, 'no', mixed).game; + game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target)); + game = act(game, f.target, f.payment); + assert.ok(!game.ready.includes(f.target)); assert.ok(!promise(game, f.target).released); + rejected(game, f.target, { type: 'ready' }); + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); + assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, null); + game = act(game, f.target, guide.action); game = close(game); + assert.equal(game.turnActionPromises![0].alliance?.closed, true); + assert.equal(game.turnActionPromises![0].alliance?.partner, null); physical(game, f); +}); + +void test('native prospective alliance proposal preserves a still-owed ordinary payment before forming with its recipient', () => { + const f = fixture({ consent: 'none' }), mixed = group('and', payment(f.partner), ship()); + let game = bind(f, 'yes', mixed).game; + rejected(game, f.target, f.allianceAction); + game = act(game, f.target, { ...f.payment, target: f.partner }); + assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.partner).bribes, 2); + game = act(game, f.target, f.allianceAction); + game = act(game, f.partner, { type: 'alliance', target: f.target }); + assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).released); + game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state)); + game = settle(act(game, f.target, f.shipment)); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 6); physical(game, f); +}); + +for (const response of ['yes', 'no'] as const) + void test(`native ${response} frozen-constant alliance history also reopens own Ready after an ordinary payment`, () => { + const f = fixture(), mixed = response === 'yes' + ? group('or', fact(f.spiceFact), partner(f.partner)) : group('and', fact(f.falseSpiceFact), partner(f.partner)); + let game = bind(f, response, mixed).game; + assert.equal(promise(game, f.target).fulfilled, true); + const receipt = structuredClone(game.turnActionPromises![0]); + game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target)); + game = act(game, f.target, f.payment); + assert.ok(!game.ready.includes(f.target)); assert.deepEqual(game.turnActionPromises![0], receipt); + physical(game, f); + }); + +void test('native definite final-alliance answers uniformly reopen only respondent Ready even for folded current-fact constants', () => { + const f = fixture(); + for (const response of ['yes', 'no'] as const) { + const mixed = response === 'yes' ? group('or', fact(f.spiceFact), partner(f.partner)) + : group('and', fact(f.falseSpiceFact), partner(f.partner)); + let game = act(f.game, f.target, { type: 'ready' }); game = act(game, f.partner, { type: 'ready' }); + assert.ok(game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner)); + game = bind(f, response, mixed, game).game; + assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner)); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed); + physical(game, f); + } +}); + +void test('native exact alliance and Ready guides retain current source phase owner and raw private history through actual closure', () => { + const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient)); + let game = bind(f, 'yes', mixed).game; 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.target).truthHistory.at(-1)?.question, { ...f.question, mixed }); + for (const actor of [f.asker, f.partner]) { + assert.deepEqual(viewGame(game, actor).turnActionPromises, []); assert.equal(viewGame(game, actor).turnActionGuide, null); + } + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); + assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.partner); + assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event); + assert.equal(guide.action.turnActionPromisePhase, 1); + rejected(game, f.asker, guide.action); + rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-combined-alliance-event' }); + rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 2 }); + rejected(game, f.target, { ...guide.action, target: f.asker }); + const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire); + assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, wire); + const ready = viewGame(game, f.target).turnActionGuide; assert.ok(ready); + assert.equal(ready.action.type, 'ready'); assert.equal(ready.action.turnActionPromisePhase, 1); + game = act(game, f.target, JSON.parse(JSON.stringify(ready.action))); + assert.equal(game.turnActionPromises![0].alliance?.closed, false); assert.ok(!promise(game, f.target).fulfilled); + rejected(game, f.target, ready.action); game = close(game); + assert.equal(promise(game, f.target).fulfilled, true); rejected(game, f.target, wire); physical(game, f); +}); + +void test('all four minimal profiles actually answer and complete combined original alliance payment Ready and later shipment guides', () => { + const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed); + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(asked); const actions: TruthTurnAllianceStep[] = []; + 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 < 160 && !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, difficulty); assert.deepEqual(action, guide.action); + game = settle(act(game, f.target, action, actions), actions); + } else { + const next = nativeStep(game); assert.ok(next, difficulty); game = act(game, next.actor, next.action, actions); + } + } + assert.equal(promise(game, f.target).fulfilled, true, difficulty); + assert.equal(player(game, f.target).ally, f.partner); assert.equal(player(game, f.target).spice, 6); + for (const type of ['alliance', 'bribe', 'ready', 'ship']) + assert.ok(actions.some(step => step.actor === f.target && step.action.type === type), `${difficulty}: ${type}`); + physical(game, f); + } +}); diff --git a/tests/turn-action-promises.test.ts b/tests/turn-action-promises.test.ts index 65f31c24..b10df49e 100644 --- a/tests/turn-action-promises.test.ts +++ b/tests/turn-action-promises.test.ts @@ -19,6 +19,7 @@ import { recordTurnActionPayment, recordTurnActionShipment, recordTurnActionRevival, + settleTurnActionAlliance, turnActionBranches, turnActionHasPayments, turnActionValue, @@ -38,6 +39,7 @@ const bid = (minimum = 3, lot = 1): Expression => ({ kind: 'action', action: { k const forces = (minimum = 3, counter: 'total' | 'normal' | 'elite' = 'total'): Expression => ({ 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 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, @@ -61,6 +63,9 @@ function prepared(mixed: Expression, answer = true, id = 'turn-action:1'): TurnA revival: { total: 0, normal: 0, elite: 0, leaders: [], closed: false }, } }; } +function nexus(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise { + return { ...promise(mixed, answer, id), alliance: { source: 'nexus:1', partner: null, closed: false } }; +} 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) { @@ -557,3 +562,124 @@ void test('preparation metadata and actual events reject foreign sources, mismat assert.throws(() => recordTurnActionRevival([full], 'respondent', 1, { kind: 'forces', amount: 1, elite: 0 }), TurnActionError); assert.deepEqual(full, before); }); + +void test('final-alliance leaves use exact public partner fields and require a private original Nexus stage', () => { + const leaf = { kind: 'alliance', partner: 'recipient' }; + assert.deepEqual(parseTurnActionLeaf(leaf), leaf); + assert.notEqual(parseTurnActionLeaf(leaf), leaf); + for (const invalid of [{ ...leaf, partner: '' }, { ...leaf, partner: ' recipient' }, + { ...leaf, source: 'nexus:1' }, { ...leaf, closed: true }, { ...leaf, answer: true }, + { ...leaf, turn: 1 }, { ...leaf, minimum: 1 }]) + assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError); + assert.throws(() => turnActionValue(promise(alliance())), TurnActionError); + const folded = promise(group('or', fact(), alliance())); + assert.equal(folded.compiled.kind, 'constant'); + assert.throws(() => turnActionValue(folded), TurnActionError, 'Original alliance clauses require their source even after folding'); + validateTurnActionPromises([nexus(group('and', alliance(), pay()))], seats); + validateTurnActionPromises([{ ...prepared(group('and', bid(), forces())), + alliance: { source: 'nexus:1', partner: null, closed: false } }], seats); + assert.equal('alliance' in closeTurnActionTransport([promise(pay())], 'respondent', 1)[0], false); +}); + +void test('open final membership remains unresolved and lazy branches retain whole AND OR and No meaning', () => { + const original = nexus(group('or', alliance(), pay())); + assert.equal(turnActionValue(original), false); + assert.deepEqual(branches(original), [[{ kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: true }], [signedPay()]]); + assert.deepEqual(branches(nexus(group('and', alliance(), pay()), false)), [ + [{ kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: false }], + [signedPay('recipient', 3, 0, false)], + ]); + assert.deepEqual(branches(nexus(group('or', alliance(), pay()), false)), [[ + { kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: false }, signedPay('recipient', 3, 0, false), + ]]); + assert.deepEqual(branches(nexus(group('and', alliance(), alliance()))), [[ + { kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: true }, + ]]); + assert.deepEqual(branches(nexus(group('and', alliance(), alliance('other')))), [], + 'One final source has only one final partner'); + assert.deepEqual(branches(nexus(group('or', alliance(), alliance('other')))), [ + [{ kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: true }], + [{ kind: 'alliance', partner: 'other', source: 'nexus:1', answer: true }], + ]); +}); + +void test('exact Nexus closure snapshots final membership once and preserves later payment alternatives', () => { + const original = nexus(group('and', alliance(), pay())); + assert.equal(settleTurnActionAlliance([original], 1, 'other-nexus', { respondent: 'recipient' })[0], original); + assert.equal(settleTurnActionAlliance([original], 2, 'nexus:1', { respondent: 'recipient' })[0], original); + const closed = settleTurnActionAlliance([original], 1, 'nexus:1', { respondent: 'recipient' }); + assert.deepEqual(closed[0].alliance, { source: 'nexus:1', partner: 'recipient', closed: true }); + assert.equal(closed[0].fulfilled, false); + assert.deepEqual(branches(closed[0]), [[signedPay()]]); + assert.equal(settleTurnActionAlliance(closed, 1, 'nexus:1', { respondent: 'other' })[0], closed[0]); + assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true); + assert.deepEqual(original.alliance, { source: 'nexus:1', partner: null, closed: false }); + const alternative = settleTurnActionAlliance([nexus(group('or', alliance(), pay()))], 1, 'nexus:1', { respondent: null }); + assert.equal(alternative[0].fulfilled, false); + assert.equal(alternative[0].released, false); + assert.deepEqual(branches(alternative[0]), [[signedPay()]]); + assert.equal(recordTurnActionPayment(alternative, 'respondent', 1, payment())[0].fulfilled, true); + validateTurnActionPromises(alternative, seats); +}); + +void test('a whole No made true by final membership releases while later actual payments still reject live No violations', () => { + const no = nexus(alliance(), false); + const conflict = settleTurnActionAlliance([no], 1, 'nexus:1', { respondent: 'recipient' }); + assert.equal(conflict[0].released, true); + assert.equal(conflict[0].fulfilled, false); + assert.equal(turnActionValue(conflict[0]), true); + validateTurnActionPromises(conflict, seats); + const beforePayment = nexus(group('and', alliance(), pay()), false); + const matching = settleTurnActionAlliance([beforePayment], 1, 'nexus:1', { respondent: 'recipient' }); + assert.equal(matching[0].released, false); + assert.equal(matching[0].fulfilled, false); + assert.deepEqual(branches(matching[0]), [[signedPay('recipient', 3, 0, false)]]); + assert.throws(() => recordTurnActionPayment(matching, 'respondent', 1, payment()), TurnActionError); + const paidFirst = recordTurnActionPayment([beforePayment], 'respondent', 1, payment()); + assert.equal(paidFirst[0].released, false); + const conflictAfterPayment = settleTurnActionAlliance(paidFirst, 1, 'nexus:1', { respondent: 'recipient' }); + assert.equal(conflictAfterPayment[0].released, true); + const nonmatching = settleTurnActionAlliance([beforePayment], 1, 'nexus:1', { respondent: null }); + assert.equal(nonmatching[0].fulfilled, false, 'Whole No still waits for its remaining payment opportunity'); + const finished = finishTurnActionTurn(nonmatching, 'respondent', 1); + assert.equal(finished[0].fulfilled, true); + const plainNo = settleTurnActionAlliance([no], 1, 'nexus:1', { respondent: 'other' }); + assert.equal(plainNo[0].fulfilled, true); + assert.equal(plainNo[0].released, false); +}); + +void test('expiry releases an unresolved original Nexus without inventing membership and preserves every terminal row', () => { + const originals = [nexus(alliance()), nexus(alliance(), false, 'turn-action:2')]; + const finished = finishTurnActionTurn(originals, 'respondent', 1); + for (const record of finished) { + assert.equal(record.released, true); + assert.equal(record.fulfilled, false); + assert.deepEqual(record.alliance, { source: 'nexus:1', partner: null, closed: false }); + } + const paid = recordTurnActionPayment([nexus(group('or', alliance(), pay()), true, 'turn-action:3')], + 'respondent', 1, payment())[0]; + assert.equal(paid.fulfilled, true); + assert.equal(finishTurnActionTurn([paid], 'respondent', 1)[0], paid); + const terminal = [...finished, paid]; + assert.ok(settleTurnActionAlliance(terminal, 1, 'nexus:1', { respondent: 'recipient' }) + .every((row, index) => row === terminal[index])); + assert.ok(recordTurnActionPayment(terminal, 'respondent', 1, payment()) + .every((row, index) => row === terminal[index])); + validateTurnActionPromises(terminal, seats); +}); + +void test('final-membership stages reject temporary pairs, extra metadata, missing snapshot players and foreign partner clauses', () => { + const original = nexus(group('and', alliance(), pay())); + for (const stage of [undefined, null, {}, { source: '', partner: null, closed: false }, + { source: 'nexus:1', partner: 'recipient', closed: false }, { source: 'nexus:1', partner: 'respondent', closed: true }, + { source: 'nexus:1', partner: null, closed: 'yes' }, { source: 'nexus:1', partner: null, closed: false, offers: {} }]) + assert.throws(() => turnActionValue({ ...original, alliance: stage } as TurnActionPromise), TurnActionError); + assert.throws(() => settleTurnActionAlliance([original], 1, 'nexus:1', {}), TurnActionError); + assert.throws(() => settleTurnActionAlliance([original], 1, 'nexus:1', { respondent: 'respondent' }), TurnActionError); + for (const partner of ['missing', 'respondent']) + assert.throws(() => validateTurnActionPromises([nexus(alliance(partner))], seats), TurnActionError); + const foreignSnapshot = { ...original, alliance: { source: 'nexus:1', partner: 'missing', closed: true } }; + assert.throws(() => validateTurnActionPromises([foreignSnapshot], seats), TurnActionError); + const foreignCompiled = { ...original, compiled: { kind: 'action', action: { kind: 'alliance', partner: 'other' } } }; + assert.throws(() => turnActionValue(foreignCompiled as TurnActionPromise), TurnActionError); +});
Choose a player with a supported ordinary bid, Revival, payment, shipment or ground-movement opportunity this turn. + {!g.truthTurnActionTargets.includes(target) &&
Choose a player with a supported alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunity this turn. Ordinary payments may remain available after their Shipment and Movement opportunity ends.
{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}
Alliance conditions require the current open Nexus.
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. 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.