diff --git a/components/game-table.tsx b/components/game-table.tsx index 7e318360..559a1b3e 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -27,6 +27,7 @@ import { ShipmentPromises } from './shipment-promises'; import { AlliancePromises } from './alliance-promises'; import { BribePromises } from './bribe-promises'; import { RevivalPromises } from './revival-promises'; +import { LeaderRevivalPromises } from './leader-revival-promises'; import { BidPromises } from './bid-promises'; import { HomeworldShipment } from './homeworld-shipment'; import { HomeworldRevivalDeployment } from './homeworld-revival-deployment'; @@ -2036,6 +2037,7 @@ export function GameTable({ )} {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } {g.guildBetrayalReaction ? ( diff --git a/components/leader-revival-claim-fields.tsx b/components/leader-revival-claim-fields.tsx new file mode 100644 index 00000000..2079e9d7 --- /dev/null +++ b/components/leader-revival-claim-fields.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { parseLeaderRevivalClaim, type LeaderRevivalClaim } from '@/game/leader-revival-promises'; + +type LeaderChoice = { id: string; name: string }; +export function leaderRevivalClaimInputError(value: unknown, leaders?: readonly LeaderChoice[]): string | null { + try { + const claim = parseLeaderRevivalClaim(value); + return leaders && !leaders.some(leader => leader.id === claim.leader) + ? 'Choose one of the current named leaders.' : null; + } catch (error) { return error instanceof Error ? error.message : 'Choose the current turn and a named leader.'; } +} + +/** The parent supplies the current turn and authorized public name choices. + * Eligibility and the native return quote remain authoritative on the server. */ +export function LeaderRevivalClaimFields(props: { + value: LeaderRevivalClaim; + onChange: (claim: LeaderRevivalClaim) => void; + leaders: readonly LeaderChoice[]; + disabled?: boolean; +}) { + const error = leaderRevivalClaimInputError(props.value, props.leaders); + return
+ Future ordinary leader Revival +

Turn {props.value.turn}

+ + + {error &&

{error}

} +

+ Ask whether this named leader will actually return through ordinary native Revival after the answer this turn. + A request alone is not a return. Native revival cycles, costs and turn order still apply; + Ghola, foreign leaders and special card returns do not count. +

+
; +} diff --git a/components/leader-revival-promises.tsx b/components/leader-revival-promises.tsx new file mode 100644 index 00000000..5673c66c --- /dev/null +++ b/components/leader-revival-promises.tsx @@ -0,0 +1,96 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { leaderRevivalClaimText, leaderRevivalPromiseModeSupported, liveLeaderRevivalPromises } from '@/game/leader-revival-promises'; +import { Button } from './ui/button'; + +type LeaderRevivalPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; +const leaderName = (game: GameView, id: string) => id === 'kwisatz' ? 'Kwisatz Haderach' + : game.allLeaders.find(leader => leader.id === id)?.name ?? 'the named leader'; + +/** Forward the exact projected preparation or ordinary return packet. The + * client does not reconstruct leader eligibility, cycle restrictions or cost. */ +export function LeaderRevivalPromiseGuideButton(props: LeaderRevivalPromiseProps) { + const game = props.game, guide = game.leaderRevivalPromiseGuide; + if (!guide) return null; + const actor = game.me, turn = game.turn, phase = guide.phase, order = game.revivalOrder; + const action = guide.action, event = action.leaderRevivalPromiseEvent, signature = JSON.stringify(action); + const canAct = () => { + const current = props.game, me = current.players.find(player => player.id === current.me); + const payment = phase === 3 && action.type === 'decision' && + current.decision?.kind === 'auctionPayment' && current.decision.player === actor; + const native = current.richeseAuction; + const nativePass = phase === 3 && action.type === 'richeseBid' && native && + native.event === action.event && !native.outcome && native.eligible.includes(actor) && + (native.method === 'silent' ? !native.submitted.includes(actor) && action.amount === 0 + : native.active === actor && action.amount === null) && action.allyPayment === 0; + const request = current.revivalRequests[actor]; + const acceptedQuote = action.type === 'acceptLeaderRevival' && !!request && !request.declined && + typeof request.price === 'number' && request.price === action.leaderRevivalPrice; + const opportunity = phase === 2 + ? !current.ready.includes(actor) && (action.type === 'charity' || action.type === 'ready') + : phase === 3 + ? payment || nativePass || action.type === 'passBid' && !native && current.auction?.active === actor + : phase === 4 && !current.ready.includes(actor) && + (action.type === 'reviveLeader' || action.type === 'reviveKwisatz' || acceptedQuote || action.type === 'revive' || action.type === 'ready') && + current.revivalOrder === order && (!current.revivalOrder || current.revivalOrder.turn === turn && + current.revivalOrder.remaining[0] === actor && current.active === actor); + return !props.busy && current === game && leaderRevivalPromiseModeSupported(current) && + current.status === 'playing' && current.phase === phase && current.me === actor && current.turn === turn && opportunity && + current.leaderRevivalPromiseGuide === guide && current.leaderRevivalPromiseGuide.phase === phase && + current.leaderRevivalPromiseGuide.action === action && JSON.stringify(action) === signature && typeof event === 'string' && + liveLeaderRevivalPromises(current.leaderRevivalPromises, actor, turn).some(promise => promise.event === event) && !!me && !me.autopilot && + !current.truthtrance && !current.response && (!current.decision || payment) && !current.phaseOpening && + !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; + }; + return ; +} + +export function LeaderRevivalPromises(props: LeaderRevivalPromiseProps) { + const own = props.game.leaderRevivalPromises.filter(promise => promise.player === props.game.me); + if (!own.length) return null; + const live = liveLeaderRevivalPromises(own, props.game.me, props.game.turn).length > 0; + const other = props.game.revivalOrder?.remaining[0]; + const waiting = live && (props.game.phase < 4 || props.game.phase === 4 && other && other !== props.game.me); + return
+

Your leader Revival promises

+
    {own.map(promise =>
  • + Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {leaderRevivalClaimText(promise, id => leaderName(props.game, id))}. + {promise.released ? ' Released: this commitment is no longer possible.' + : promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for this turn.'} +
  • )}
+

+ Yes requires the named leader’s future actual ordinary native return while it remains possible. + No forbids that named ordinary return until your Revival opportunity ends this turn. + A request alone is not a return. Prior returns, Ghola, foreign leaders and special card returns do not count. +

+

+ Original revival cycles, costs, limits and turn order still apply. + Preparation may return forces or complete an earlier payment without returning the named leader. + A guide may accept an existing, current Tleilaxu quote; it does not arrange a new agreement or promise future consent. + {' '}Leader Revival promise rules. +

+ {props.game.phase < 4 &&

+ Your leader Revival opportunity is still ahead. Continue the current phase and preserve its required resources; + Charity and auction preparation do not themselves fulfill a named-leader answer. +

} + {waiting && props.game.phase === 4 &&

Your leader Revival opportunity is still ahead; this promise remains visible while the current actor finishes.

} + +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 925bf8ad..0efc38bf 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,4 +1,6 @@ 'use client'; +import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from './leader-revival-claim-fields'; +import type { LeaderRevivalClaim } from '@/game/leader-revival-promises'; import { MovementClaimFields } from './movement-claim-fields'; import { AllianceClaimFields, allianceClaimInputError } from './alliance-claim-fields'; import type { AllianceClaim } from '@/game/alliance-promises'; @@ -61,8 +63,14 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' >('fact'); + const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => + leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); + const leaderRevivalClaim: LeaderRevivalClaim = { turn: g.turn, leader: revivalLeader }; + const leaderRevivalFaction = g.players.find(player => player.id === target)?.faction; + const leaderRevivalChoices = [...g.allLeaders.filter(leader => leader.faction === leaderRevivalFaction), + ...(g.advanced && leaderRevivalFaction === 'atreides' ? [{ id: 'kwisatz', name: 'Kwisatz Haderach' }] : [])]; const [alliancePartner, setAlliancePartner] = useState(g.me); const allianceClaim: AllianceClaim = { turn: g.turn, partner: alliancePartner }; const alliancePartners = g.players.filter(player => player.id !== target); @@ -113,6 +121,7 @@ export function Truthtrance({ } : kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } : kind === 'movement' ? { kind, target, ...movement } + : kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim } : kind === 'alliance' ? { kind, target, ...allianceClaim } : kind === 'bribe' ? { kind, target, ...bribeClaim } : kind === 'revival' ? { kind, target, ...revivalClaim } @@ -224,6 +233,13 @@ export function Truthtrance({ ))} + ) : w.question!.kind === 'leaderRevival' ? ( + <> +

This concerns a future actual ordinary return of the named leader before your Revival opportunity ends. Native cost, eligibility, cycle and order still apply. A request alone, Ghola card, special or foreign-ghola return does not fulfill it.

+ {(g.truthLeaderRevivalAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'unknown'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'alliance' ? ( <>

This answer concerns actual membership when this Nexus closes. An offer alone does not form an alliance. Yes remains binding after formation; No requires ending without this named partner. Consent that is not yet available may be unknown, letting the holder ask again or save the card.

@@ -371,6 +387,7 @@ export function Truthtrance({ + @@ -388,7 +405,16 @@ export function Truthtrance({ )} - {kind === 'alliance' ? ( + {kind === 'leaderRevival' ? ( + <> + setRevivalLeader(claim.leader)} /> +

Only future actual native leader returns count. Keep the original cost, physical eligibility, revival cycle and allowance. Known owned preparation may precede the return; no future foreign agreement or price is assumed.

+ {!g.truthLeaderRevivalTargets.includes(target) &&

Choose a supported participant with a remaining ordinary Revival opportunity this turn. Optional compositions remain unfinished.

} +

{truthQuestionText(question, leaderName, locationName, name)}

+ + ) : kind === 'alliance' ? ( <> setAlliancePartner(claim.partner)} /> @@ -518,6 +544,7 @@ export function Truthtrance({ 'Ask publicly', { type: 'truthAsk', question }, (kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) || + (kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) || (kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) || (kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) || (kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) || @@ -542,7 +569,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
Truthtrance answers · Turn {g.turn}

- Supported battle-plan, Nexus-alliance, bid, revival, bribe, movement and reserve-shipment + Supported battle-plan, Nexus-alliance, bid, force/leader revival, bribe, movement and reserve-shipment answers are enforced. Current-turn freeform promises remain binding when possible, but their automatic enforcement is unfinished.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 40651e57..f991ea3d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,38 @@ open mode gates. ## Current checkpoint and work +**Truthtrance / named ordinary leader revival — 26 October. Prototyped / Partial.** +Quiet phases0–4 now support future personal native leader/Kwisatz return +questions in bare native Basic/Advanced. Original identity/custody, cohort, +cost, slot and response outcomes remain. Force/leader answers share one purse, +native allowance and accepted order; existing bid/bribe/shipment/movement +commitments retain funding. Persistent exact guides prepare, return or finish; +minimal legal AI is connected. An actual priced personal negotiation can be +accepted without inventing foreign agreement. Native Tleilaxu self free-return +income can prepare the payment. The interval/witness/release composition is +provisional; optional compositions and complete-mode acceptance remain open. + +Root integration and disjoint pure/native/control lanes pass **19 files,208/208**, +types/lint/build and **six native paths**,23 GameTable SSR renders/nine actual +guide callbacks. Original H/A/T Ix47 Basic102/Advanced108-action sources earn a +real projectile death, leaving T4 with one dead/four living leaders;12/11 further +actions bind and return that native leader for2. Other paths keep No through a +free-force return, earn actual bank1 after a bribe leaves T1, and carry an early +post-Storm question through real phases. A103-action original projectile/Tooth +source earns two real deaths;15 further actions request/quote3, bind, reprice4, +reject the old guide and actually return A's named leader for4. All47 cards stay +physical. Ten pure,16 native,six controlled UI and two native funding cases +cover joint clauses, earlier bribes and legal profiles. Ghola cannot voluntarily +erase the only ordinary-return Yes before its later income response. + +Frozen v4 report/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-leader-revival-proof-v4/`; +journeys/HTML: adjacent `20261026-truth-leader-revival-runtime/`. +No saved-room/D1/server/reset/restart/deployment operation is performed. +Original normal-cohort/Auditor/Duke/KH commitment and opposing-release journeys, +browser/mobile/deployed and final assurance remain due. +[Source and boundaries](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md). + **Truthtrance / current-Nexus alliance membership — 26 October. Prototyped / Partial.** Named final partnership questions now use actual reciprocal consent in quiet ordinary Nexus negotiation on registered bare native Basic/Advanced tables. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b15ab108..c8703d4f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,40 @@ # Dune implementation status +## 26 October 2026 — Truthtrance named personal native leader returns + +**Prototyped / Partial.** Named leader/Kwisatz future-return questions now work +in quiet phases0–4 on bare native Basic/Advanced. Shared native quote and physical +accounting retain identity, custody, cost, cohort and slots; one force/leader +completion owns their purse and accepted order. Existing priced personal early +negotiations and native Tleilaxu self free-return income can prepare real returns. +Other financial/movement commitments remain; card/special/foreign-ghola returns +do not fulfill the named claim. Persistent exact guides and minimal legal AI +connect preparation, actual return and Finish. Provisional interval/witness/ +release meaning and all mode gates remain explicit. + +Root integration with disjoint pure/native/control lanes passes **19 files, +208/208**, types/lint/build and **six native paths**,23 GameTable SSR renders/ +nine actual callbacks. Original H/A/T Ix47 Basic102/Advanced108 prefixes earn +the actual projectile death and T4;12/11 further actions bind and return Hidar +for2. A No path permits an independent free return and its actual bank1 without +returning the leader. Actual bribe3 leaves T1; free return/income then funds2. +A genuine83-action post-Storm source adds33 actions through native phases. +A103-action projectile/Tooth source earns both native deaths;15 more actions +request, quote3, bind, reprice4, reject stale3 and actually return A's leader. +All47 cards remain; no played hand, purse, clock, death or promise is installed. +Ten pure,16 native,six controlled UI and two native funding cases cover No, +prior/source separation, joint answers/budgets, waiting and minimal policies. +Feedback fixes voluntary Ghola evasion before its later income response and +retains turn-wide bribe No beyond Revival; optional record reads are guarded. + +Frozen v4 proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-leader-revival-proof-v4/`; +journeys/HTML: adjacent `20261026-truth-leader-revival-runtime/`. +No saved-room/D1/server/reset/restart/deployment operation is performed. +Original normal-cohort/Auditor/Duke/KH commitment and opposing-release journeys, +wider preparations/compositions, browser/deployed and final assurance remain due. +[Contract](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md). + ## 26 October 2026 — Truthtrance current-Nexus alliance membership **Prototyped / Partial.** Current quiet ordinary Nexus questions bind the diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index fb0255ba..2468861c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Truthtrance named native leader returns — 26 October 2026 + +Compose general current-turn Truthtrance authority (FAQp8; supplied Advanced +physical39) with original personal leader/KH return. Supplied physical9 retains +cohort, price, ordinary slot and exceptions; native Basic/Advanced protocols +remain. Under standing14October priority, Yes means an actual named future +native return before this opportunity closes; No forbids it. Requests and card/ +special/foreign-ghola returns do not fulfill it. Accepted personal early +negotiations count only when actually returned; never invent foreign agreement +or a price. Force/leader commitments share one purse/slot planner and accepted +release order. This source/interval/owned-witness policy is provisional software +meaning, not a dedicated publisher ruling. Bare native scope and mode gates +remain. [Contract](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md). + ## Truthtrance current-Nexus alliance membership — 26 October 2026 Apply general current-turn Truthtrance authority (FAQp8; supplied Advanced diff --git a/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md b/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md new file mode 100644 index 00000000..8b61a219 --- /dev/null +++ b/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md @@ -0,0 +1,119 @@ +# Truthtrance named leader revival promises + +## Native future-return prototype — 26 October 2026 + +**Prototyped / Partial.** Registered bare native Basic/Advanced tables support +`{kind:'leaderRevival',target,turn,leader}` in quiet phases0–4 with a remaining +ordinary Revival opportunity. Name an original own leader, Ecaz's shared Duke +where present, or the Advanced Atreides `kwisatz` token. Printed public names +support authoring; private answers and own records/guide stay respondent-only. + +Only an actual future personal native leader/KH return after the answer counts. +Direct requests and actually accepted native early negotiations share real +return accounting. A request, quote, unaccepted negotiation, prevented return, +card/special revival or foreign ghola does not fulfill Yes. Prior returns and +the separate force ledger do not count. No prevents that named ordinary return. + +## Authority and interpretation + +General current-turn Truthtrance authority is recorded in the +[nonbattle audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) and +[force-return contract](TRUTHTRANCE_REVIVAL_PROMISES.md). Supplied Advanced +physical39 was read for this slice. Its Leader Revival passage, physical9/ +printed7, retains cohort, one-leader allowance, fighting-strength payment and +faction exceptions. Native rules remain in `game/revival.ts`, +[Ecaz revival](ECAZ_REVIVAL_RULES.md) and +[Advanced opportunity order](ADVANCED_REVIVAL_ORDER.md). + +Under standing14October choose-recommended authority, bind the named future +native return until the respondent's actual Revival opportunity closes. Basic +No persists to phase close because readiness may reopen; stamped Advanced No +ends with the native opportunity. Source/interval/owned-completion/release +meaning is provisional software composition, not a dedicated publisher ruling. +Basic keeps its original native protocol. + +## Connected behavior + +`game/leader-revival-promises.ts` owns canonical claims, required/forbidden +identities, immutable actual-return recording and validation. Same-identity Yes +answers can share one real return; distinct Yes identities need actual native +slots. The question grants no extra allowance. A matching No rejects before a +voluntary native request opens its responses. + +The original leader quote and physical/cohort/slot accounting serve actual +returns and private witnesses. One completion planner pays required leaders +and reserves their native slots before aggregate force search. Force and +leader answers share accepted order, with existing unstamped records first. +Voluntary invoices, spending and Finish cannot erase the last completion. +Completed opposing impossibility releases only incompatible Yes in that order. +Original responses and their actual outcomes remain authoritative. + +Earlier phases retain current dead/captured/foreign custody and use existing +owned Charity, opening-income and known auction-payment witnesses, with only +the real future normal ledgers reset. No future death, foreign agreement/price, +unknown income or future draw is assumed. Earlier bid/bribe/shipment/movement +funding is retained. Actual accepted personal negotiations count when returned; +an already-priced, not-declined request has an owned acceptance guide. The +planner never invents a future negotiation, foreign agreement or price. +Private witnesses execute no foreign income, skills or continuations. An +actual Tleilaxu self free-return receipt may prepare a needed leader payment; +the real return and its response must complete before the live income exists. + +Public-name fields, preview, target-only answers and a persistent own panel +connect to the table. Exact event-bound guides prepare, make a jointly required +force return, return a leader/KH or finish at the actual owned opportunity. +Pending decisions and room controls remain. All four minimal policies use the +same feasible answers/native guide; strategy and calibration stay deferred. + +## Evidence and remaining gates + +Pure and controlled UI cases plus original battle/death sources are in +`tests/leader-revival-promises.test.ts`, +`tests/truth-leader-revival-controls.test.tsx`, +`tests/fixture-truth-leader-revival.ts` and +`tests/truth-leader-revival-runtime.test.ts`; native shared-purse cases are in +`tests/truth-leader-revival-funding.test.ts`. Ten pure,16 native,six controlled +UI and two native funding cases are part of the frozen **19-file,208/208** union. +`npm run check:quick`, `npm run build` and six native runtime paths pass on the +same unchanged source. The actual GameTable renders23 times and nine real +hook-free guide callbacks submit their original packets. + +| Original source | Prefix actions | Further actions | Actual result | +| --- | ---: | ---: | --- | +| Basic H/A/T Ix47 | 102 | 12 | Projectile kills Hidar; future native return costs2, T4→2 | +| Advanced H/A/T Ix47 | 108 | 11 | Same real death/return under actual ordered Revival | +| Basic named No | 102 | 12 | Exact ordinary return rejects; free force/bank1 remains independent, No ends at actual phase close | +| Basic owned income | 102 | 14 | Bribe3 leaves T1; actual free force earns bank1; ordinary leader then costs2, ending T0 | +| Advanced post-Storm | 83 | 33 | Early question remains pending through actual phases and returns the leader at its own opportunity | +| Basic projectile/Tooth | 103 | 15 | Both leaders actually die; A requests, T quotes3, Yes binds, T reprices4, old guide rejects, real acceptance pays4 | + +Both original Truths and all47 original cards retain physical inventory. +Only still-unopened original deal/Spice positions and scalar lotteries are +selected. Native sources install no played hand, purse, clock, death, question +or promise. Four minimal profiles submit legal actual returns in both source +modes. Cases also cover prior-return exclusion, Ghola/ordinary separation, +same/conflicting clauses, native ordered waiting, free-income preparation +blocked by Force No, and actual already-priced negotiations for Yes and No. +Controlled projections remain UI evidence, not native acquisition/death/payment. + +Feedback repairs voluntary Ghola evasion: its physical return cannot erase the +last ordinary-return Yes by opening a later income response owned elsewhere. +The funding assertion retains a turn-wide bribe No after Revival. Optional +negotiation-record reads are guarded. A transient direct quick-check process +ended143; the final source-bound v4 types/lint and all remaining steps completed +successfully. Earlier failed proof directories remain diagnostic data. + +Frozen v4 report/verified manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-leader-revival-proof-v4/`. +Exact commands/logs are in `report.json`; journeys, SSR HTML and native harness +are in adjacent `20261026-truth-leader-revival-runtime/`. Final evidence prose +is a separately recorded three-document overlay. SSR/callbacks are local +engine/control evidence, not browser clicks. + +The native source targets Tleilaxu's own early return, not a five-disc cohort +opening. Dedicated original normal-cohort, Auditor, Duke and KH commitment +journeys, native opposing-prevention/release histories, wider negotiated/card +preparation, optional modules and grouped revival +expressions remain due. No complete faction/mode, all combinations, browser/ +mobile/deployed or final assurance is certified. No saved-game/database/server, +reset/restart/deployment operation is performed; no start/publication gate opens. diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md index 77bec3c0..1ae6c736 100644 --- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md +++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md @@ -1,5 +1,9 @@ # Truthtrance nonbattle readiness and first implementation slice +The [named-leader follow-up](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md) adds actual +personal native leader/KH return commitments alongside the separate force +ledger. It supersedes that named-return absence only; broader promises remain. + The [current-Nexus alliance follow-up](TRUTHTRANCE_ALLIANCE_PROMISES.md) adds actual final-membership commitments using owned reciprocal consent, with unknown/save when consent is unavailable. This supersedes the historical diff --git a/docs/TRUTHTRANCE_REVIVAL_PROMISES.md b/docs/TRUTHTRANCE_REVIVAL_PROMISES.md index 766fc526..02ba52da 100644 --- a/docs/TRUTHTRANCE_REVIVAL_PROMISES.md +++ b/docs/TRUTHTRANCE_REVIVAL_PROMISES.md @@ -1,5 +1,10 @@ # Truthtrance native force-revival promises +The [named-leader follow-up](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md) adds a +separate future personal native leader/KH return question sharing the ordinary +force planner's purse and native slots. Leaders still do not count toward the +force-counter question below; broader special/card revival remains unfinished. + ## Earlier-turn follow-up — 26 October 2026 **Prototyped / Partial.** The same ordinary-return question now works in quiet diff --git a/game/bots.ts b/game/bots.ts index 8d6eaff1..9ba8fc0e 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1309,6 +1309,7 @@ function policyActions(g: GameView): Action[] { } if (g.bribePromiseGuide) return [g.bribePromiseGuide.action]; if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action]; + if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action]; if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action]; if (g.bidPromiseGuide) return [g.bidPromiseGuide.action]; if (g.truthtrance) { @@ -1317,6 +1318,10 @@ function policyActions(g: GameView): Action[] { return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }]; if (w.stage === 'answer') { if (w.question?.target !== me.id) return []; + if (w.question.kind === 'leaderRevival') { + const answer = g.truthLeaderRevivalAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'alliance') { const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; diff --git a/game/engine.ts b/game/engine.ts index ae6c9e02..fecd8404 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,6 @@ +import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequirements, + leaderRevivalPromisesSatisfied, recordPromisedLeaderRevival, validateLeaderRevivalPromises, + type LeaderRevivalClaim, type LeaderRevivalPromise } from './leader-revival-promises'; import { AlliancePromiseError, alliancePromiseModeSupported, liveAlliancePromises, alliancePromiseRequirements, alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises'; import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe, @@ -2143,6 +2146,9 @@ export type Game = { shipmentPromises?: ShipmentPromise[]; bidPromises?: BidPromise[]; revivalPromises?: RevivalPromise[]; + leaderRevivalPromises?: LeaderRevivalPromise[]; + /** Shared accepted order for typed force and named leader answers. */ + revivalPromiseSequence?: string[]; bribePromises?: BribePromise[]; alliancePromises?: AlliancePromise[]; movementPromises?: MovementPromise[]; @@ -15100,7 +15106,7 @@ function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ? } catch (error) { if (error instanceof RuleError) return null; throw error; } } function bribeCompatibleLaterPromises(g: Game, p: Player, - later: Pick, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'battle'>): boolean { + later: Pick, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'battle'>): boolean { if (!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return true; return !!bribePromiseCompletion({ ...g, ...later }, p); } @@ -15170,7 +15176,8 @@ function projectedBribes(g: Game, p: Player): BribeOptions { promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]); const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0; const movementPromises = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer); - const revivalPromises = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer); + const revivalPromises = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer); const bribePromises = liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length > 0; // Reuse the authoritative promise checks on an isolated resource-only copy. // No payment, receipt or live continuation is created by this projection. @@ -15403,6 +15410,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? [] if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length || liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length || + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length || liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) { const trial = structuredClone(g); trial.truthtrance = null; @@ -29955,6 +29963,27 @@ function projectOwnedOrdinaryRevival(g: Game, owner: string) { commitRevivalForcePieces(g, p, quote.pending); g.pendingRevival = null; g.decision = null; g.response = null; } +/** Physical leader/cohort/slot accounting shared by actual returns and private + * allowed-outcome quotes. Income, skills, logs and continuations stay native. */ +function commitRevivalLeaderPieces(g: Game, p: Player, revival: PendingRevival) { + let leader: Leader | undefined; + if (revival.kind === 'kwisatz') { + p.kwisatz!.dead = false; + p.kwisatz!.revivalCycle = Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1; + } else { + const sharedDuke = revival.kind === 'leader' && revival.leader === DUKE_VIDAL_ID; + leader = sharedDuke ? g.dukeVidal!.leader : g.players.flatMap(owner => owner.leaders).find(l => l.id === revival.leader)!; + recordEcazLeaderReturn(g, leader); + if (revival.kind === 'foreignGhola') leader.gholaBy = p.id; + else delete leader.gholaBy; + leader.dead = false; + delete leader.concealed; + if (sharedDuke) g.dukeVidal = consumeDuke(g.dukeVidal!); + } + p.leaderRevived = true; + if (g.revivalRequests) delete g.revivalRequests[p.id]; + return leader; +} function finishRevival( g: Game, quote: RevivalResumeQuote = currentRevivalResumeQuote(g, 'finish'), @@ -30022,36 +30051,27 @@ function finishRevival( { faction: p.faction, name: 'Revival' }); stageHomeworldRevivalReturn(g, p, source, group, deploymentGrant); } else { + if ((revival.kind === 'leader' || revival.kind === 'kwisatz') && + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length) + g.leaderRevivalPromises = leaderRevivalPromiseRule(() => recordPromisedLeaderRevival( + g.leaderRevivalPromises ?? [], p.id, g.turn, revival.kind === 'kwisatz' ? 'kwisatz' : revival.leader!)); + const leader = commitRevivalLeaderPieces(g, p, revival); if (revival.kind === 'kwisatz') { - p.kwisatz!.dead = false; - p.kwisatz!.revivalCycle = - Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1; log(g, `${p.name} revived Kwisatz Haderach.`, { faction: p.faction, name: 'Revival' }); } else { const sharedDuke = revival.kind === 'leader' && revival.leader === DUKE_VIDAL_ID; - const leader = sharedDuke - ? g.dukeVidal!.leader - : g.players.flatMap((owner) => owner.leaders).find((l) => l.id === revival.leader)!; - recordEcazLeaderReturn(g, leader); - if (revival.kind === 'foreignGhola') leader.gholaBy = p.id; - else delete leader.gholaBy; - leader.dead = false; - delete leader.concealed; - if (sharedDuke) g.dukeVidal = consumeDuke(g.dukeVidal!); // Automatic Karama response chains may commit after the action's before/after - if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) && - !leader.capturedBy && p.leaders.some(own => own.id === leader.id)) { + if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader!) && + !leader!.capturedBy && p.leaders.some(own => own.id === leader!.id)) { g.leaderSkills = skillRule(() => offerRevivedLeaderSkill( - g.leaderSkills!, p.id, leader.id, crypto.randomUUID())); + g.leaderSkills!, p.id, leader!.id, crypto.randomUUID())); } log( g, - `${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`, + `${p.name} revived ${leader!.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`, { faction: p.faction, name: 'Revival' }, ); } - p.leaderRevived = true; - if (g.revivalRequests) delete g.revivalRequests[p.id]; } g.pendingRevival = null; if (quote.freeIncome) { @@ -30112,11 +30132,119 @@ function normalForceRevivalQuote(g: Game, p: Player, action: Action): PendingRev checks, }; } +function normalLeaderRevivalQuote(g: Game, p: Player, action: Action): { revival: PendingRevival; cycle: number | null } { + const t = action.type; + requireRule( + g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'), + 'Only one leader revival is permitted this phase.', + ); + requireRule( + !revivalPrevented(g, p.id), + 'Tleilaxu prevented this faction’s normal revivals for this turn.', + ); + const options = leaderRevivalOptions(g, p); + const duke = t === 'reviveLeader' && action.leader === DUKE_VIDAL_ID; + if (duke) { + requireRule(p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal.'); + const blocked = ecazDukeRevivalBlock(g, p.id); + requireRule(!blocked, blocked ?? 'Duke Vidal is unavailable for revival.'); + } + const dukeQuote = duke ? quoteEcazDukeRevival(g, p.id, !!revivalDiscount(g, p)) : null; + const l = t === 'reviveLeader' + ? duke ? g.dukeVidal!.leader : p.leaders.find((l) => l.id === action.leader) + : undefined; + if (t === 'reviveKwisatz') + requireRule( + g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, + 'Kwisatz Haderach is not in the tanks.', + ); + else + requireRule( + l?.dead && !l.capturedBy && !l.gholaBy, + 'Choose a dead leader.', + ); + const option = options.leaders.find((option) => option.id === l?.id); + const normal = + t === 'reviveKwisatz' ? !!options.kwisatz : !!option && !option.early; + requireRule( + normal || p.faction === 'tleilaxu', + 'All leaders must die before a new revival cycle can begin.', + ); + const checks: PendingRevival['checks'] = []; + if (!normal) { + requireRule( + !g.revivalRules?.earlyBlocked.includes(`${p.id}:${l!.id}`), + 'Early revival of that leader was canceled this phase.', + ); + checks.push('earlyRevival'); + } + requireRule( + t === 'reviveKwisatz' ? !!options.kwisatz : !!option, + 'That leader is unavailable for revival this phase.', + ); + const normalCost = dukeQuote?.normalCost ?? l?.strength ?? 2; + const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost); + requireRule(p.spice >= cost, 'Not enough spice.'); + if (cost < normalCost) checks.push('revivalDiscount'); + return { cycle: p.faction !== 'ecaz' && (!duke || options.cycle > p.revivalCycle) ? options.cycle : null, + revival: { player: p.id, kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader', leader: l?.id, + normalCost, cost, free: 0, checks } }; +} +function personalLeaderRequestQuote(g: Game, p: Player, requested: string | undefined) { + const tleilaxu = byFaction(g, 'tleilaxu'); + requireRule(g.phase === 4 && tleilaxu, 'Tleilaxu leader negotiations require the Revival phase.'); + requireRule( + p.id !== tleilaxu!.id && !p.leaderRevived, + 'Only one negotiated leader revival is permitted per faction per phase.', + ); + requireRule(requested, 'Request a leader first.'); + requireRule( + !revivalPrevented(g, p.id), + 'Tleilaxu prevented this faction’s normal revivals for this turn.', + ); + const buyback = p.leaders.some( + (l) => l.id === requested && l.gholaBy === tleilaxu!.id, + ); + requireRule( + buyback || p.leaders.some(nativeAvailable), + 'When all native leaders are unavailable, use ordinary revival rules.', + ); + if (requested === 'kwisatz') + requireRule( + g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, + 'Kwisatz Haderach is not in the tanks.', + ); + else + requireRule( + p.leaders.some((l) => l.id === requested && l.dead && !l.capturedBy), + 'Choose one of your dead native leaders.', + ); + requireRule( + !g.revivalRules?.earlyBlocked.includes(`${p.id}:${requested}`), + 'Early revival of that leader was canceled this phase.', + ); +} +function negotiatedLeaderRevivalQuote(g: Game, p: Player): ReturnType { + const request = g.revivalRequests?.[p.id]; + requireRule(request, 'Request a leader first.'); + personalLeaderRequestQuote(g, p, request?.leader); + requireRule(!request?.declined, 'That request was declined.'); + const price = request?.price; + requireRule(typeof price === 'number', 'Wait for Tleilaxu to quote a price.'); + requireRule(p.spice >= price, 'Not enough spice for the agreed revival.'); + return { cycle: null, revival: { player: p.id, kind: request.leader === 'kwisatz' ? 'kwisatz' : 'leader', + leader: request.leader, normalCost: price, cost: price, free: 0, checks: ['earlyRevival'] } }; +} function revivalPromiseRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof RevivalPromiseError) throw new RuleError(error.message); throw error; } } +function leaderRevivalPromiseRule(calculate: () => T): T { + try { return calculate(); } + catch (error) { if (error instanceof LeaderRevivalPromiseError) throw new RuleError(error.message); throw error; } +} function revivalPromiseIntegrity(g: Game) { + leaderRevivalPromiseRule(() => validateLeaderRevivalPromises(g.leaderRevivalPromises ?? [], g.players.map(player => player.id))); revivalPromiseRule(() => validateRevivalPromises(g.revivalPromises ?? [], g.players.map(player => player.id))); } /** An unused native opportunity this turn, including earlier phases and waiting @@ -30130,15 +30258,26 @@ function revivalQuestionWindow(g: Game): boolean { !g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard; } +function stagePromisedForceReturn(g: Game, p: Player, revival: PendingRevival) { + const quote = currentRevivalResumeQuote({ ...g, pendingRevival: { ...revival, checks: [] } }, 'finish'); + requireRule(quote.outcome === 'revive', 'This ordinary force return cannot complete.'); + g.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(g.revivalPromises ?? [], p.id, + g.turn, { amount: revival.amount!, elite: revival.elite ?? 0 })); + p.spice -= revival.cost; + commitRevivalForcePieces(g, p, revival); + if (quote.freeIncome) { + g.revivalFreeIncome ??= {}; + g.revivalFreeIncome[quote.freeIncome.player] = quote.freeIncome.turn; + } + if (quote.nextResponse?.kind === 'revivalIncome' && quote.nextResponse.owner === p.id) + p.spice += quote.nextResponse.amount ?? 0; +} function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival): boolean { if (!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length && !liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) return true; const trial = structuredClone(g), actor = getPlayer(trial, p.id); trial.truthtrance = null; - if (revival) { - actor.spice -= revival.cost; - commitRevivalForcePieces(trial, actor, revival); - } + if (revival) stagePromisedForceReturn(trial, actor, revival); // This is the final aggregate: later witnesses cannot invent another ordinary // revival that would bypass this question's typed No or paid-return budget. if (!trial.ready.includes(p.id)) trial.ready.push(p.id); @@ -30155,6 +30294,44 @@ function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival) function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPromise[]): Action | null { if (g.phase !== 4) return null; if (!revivalOpportunity(g, p.id)) return null; + const leaders = leaderRevivalRequirements(g.leaderRevivalPromises ?? [], p.id, g.turn); + if (!leaders) return null; + // One completion owner reserves the actual leader price, physical cohort and + // slot before searching force returns against the resulting shared purse. + // Required identities are bounded by native discs; no scored AI shortlist. + if (leaders.required.length) { + for (const leader of leaders.required) { + const actions: Action[] = [leader === 'kwisatz' ? { type: 'reviveKwisatz' } : { type: 'reviveLeader', leader }]; + const request = g.revivalRequests?.[p.id]; + if (request?.leader === leader && typeof request.price === 'number' && !request.declined) + actions.push({ type: 'acceptLeaderRevival', leaderRevivalPrice: request.price }); + for (const action of actions) try { + const trial = structuredClone(g), actor = getPlayer(trial, p.id); + trial.truthtrance = null; + const quote = action.type === 'acceptLeaderRevival' ? negotiatedLeaderRevivalQuote(trial, actor) + : normalLeaderRevivalQuote(trial, actor, action); + stagePromisedLeaderReturn(trial, actor, quote); + if (currentRevivalPromiseCompletion(trial, actor, promises)) return action; + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + if (p.faction === 'tleilaxu' && g.revivalFreeIncome?.[p.id] !== g.turn && !revivalPrevented(g, p.id)) { + const maximum = forceRevivalRemaining(g, p), tanksElite = p.elites?.tanks ?? 0; + for (let amount = 1; amount <= maximum; amount++) + for (let elite = Math.max(0, amount - (p.tanks - tanksElite)); elite <= Math.min(amount, eliteRevivalRemaining(p, g.advanced)); elite++) { + const action: Action = { type: 'revive', amount, elite }; + try { + const trial = structuredClone(g), actor = getPlayer(trial, p.id); + trial.truthtrance = null; trial.revivalPromises = promises; + const quote = normalForceRevivalQuote(trial, actor, action); + if (quote.free <= 0) continue; + stagePromisedForceReturn(trial, actor, quote); + if (currentRevivalPromiseCompletion(trial, actor, trial.revivalPromises ?? [])) return action; + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + } + return null; + } + if (revivalPromisesSatisfied(promises, p.id, g.turn, { amount: 0, elite: 0 }) && revivalKeepsLaterPromises(g, p)) return { type: 'ready' }; if (revivalPrevented(g, p.id)) return null; @@ -30168,7 +30345,7 @@ function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPr const action: Action = { type: 'revive', amount, elite, ...(p.faction === 'ixians' ? { freeElite } : {}) }; try { const revival = normalForceRevivalQuote(g, p, action); - if (revivalKeepsLaterPromises(g, p, revival)) return action; + if (revivalKeepsLaterPromises({ ...g, revivalPromises: promises }, p, revival)) return action; } catch (error) { if (!(error instanceof RuleError)) throw error; } } } @@ -30193,7 +30370,8 @@ function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromis return revivalPromisePlan(g, p, promises)?.action ?? null; } function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick, 'shipmentPromises' | 'movementPromises'>): boolean { - if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length) return true; + if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length && + !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length) return true; const trial = { ...g, ...later }; return !!revivalPromiseCompletion(trial, getPlayer(trial, p.id)); } @@ -30214,31 +30392,93 @@ function revivalTruthAnswers(g: Game, p: Player, claim: RevivalClaim): ('yes' | function bindRevivalTruth(g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) { requireRule(revivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), 'This revival answer no longer has a legal completion.'); - (g.revivalPromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 }); + const promise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 }; + (g.revivalPromises ??= []).push(promise); + (g.revivalPromiseSequence ??= []).push(promise.event); } +function stagePromisedLeaderReturn(g: Game, p: Player, quote: ReturnType) { + const revival = quote.revival; + const leader = revival.kind === 'kwisatz' ? 'kwisatz' : revival.leader!; + if (revival.kind === 'leader' || revival.kind === 'kwisatz') + g.leaderRevivalPromises = leaderRevivalPromiseRule(() => recordPromisedLeaderRevival( + g.leaderRevivalPromises ?? [], p.id, g.turn, leader)); + if (quote.cycle !== null) p.revivalCycle = quote.cycle; + p.spice -= revival.cost; + commitRevivalLeaderPieces(g, p, revival); +} +function leaderRevivalIdentity(g: Game, p: Player, leader: string): boolean { + return leader === 'kwisatz' ? g.advanced && p.faction === 'atreides' : + leader === DUKE_VIDAL_ID ? p.faction === 'ecaz' && !!g.dukeVidal : p.leaders.some(l => l.id === leader); +} +function leaderRevivalTruthAnswers(g: Game, p: Player, claim: LeaderRevivalClaim): ('yes' | 'no' | 'unknown')[] { + if (!revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || claim.turn !== g.turn || + !leaderRevivalIdentity(g, p, claim.leader)) return []; + return (['yes', 'no'] as const).filter(answer => { + const promises = [...(g.leaderRevivalPromises ?? []), { ...claim, event: 'candidate', player: p.id, + asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }]; + const trial = { ...g, leaderRevivalPromises: promises }; + if (!revivalPromiseCompletion(trial, p) || !bribeCompatibleLaterPromises(trial, p, {})) return false; + const lot = bidOpportunity(trial); + return !lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p); + }); +} +function bindLeaderRevivalTruth(g: Game, p: Player, claim: LeaderRevivalClaim, answer: boolean, asker: string) { + requireRule(leaderRevivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a named leader-revival answer compatible with native eligibility and earlier answers.'); + const promise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer }; + (g.leaderRevivalPromises ??= []).push(promise); + (g.revivalPromiseSequence ??= []).push(promise.event); +} +function guardPromisedLeaderRevival(g: Game, p: Player, action: Action, + quote?: ReturnType) { + if (!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length && + !liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length && + !liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length && + !liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length && + !liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return; + if (!quote) { + requireRule(leaderRevivalPromisesSatisfied(g.leaderRevivalPromises ?? [], p.id, g.turn, []), + 'Complete the named leader returns required by your Truthtrance Yes before finishing Revival.'); + return; + } + const trial = structuredClone(g), actor = getPlayer(trial, p.id); + trial.truthtrance = null; + if (!trial.revivalOrder) trial.ready = trial.ready.filter(player => player !== p.id); + stagePromisedLeaderReturn(trial, actor, quote); + requireRule(!!revivalPromiseCompletion(trial, actor) && bribeCompatibleLaterPromises(trial, actor, {}), + 'This leader request would prevent you from honoring your Revival answers.'); +} +/** Reconcile the shared purse in actual answer order across both ledgers. + * Existing records without an order stamp precede newly accepted answers. */ function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action: Action }) { - if (!g.revivalPromises?.some(promise => !promise.fulfilled && !promise.released)) return; + const pending = [...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? [])] + .filter(promise => !promise.fulfilled && !promise.released); + if (!pending.length) return; revivalPromiseIntegrity(g); if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingRevival || g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return; - const kept: RevivalPromise[] = []; - const no = g.revivalPromises.filter(promise => !promise.answer && !promise.fulfilled && !promise.released); - for (const promise of g.revivalPromises) { - if (promise.fulfilled || promise.released) continue; - const same = g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && g.turn === promise.turn; - if (!promise.answer) { - if (!same || g.revivalOrder && !g.revivalOrder.remaining.includes(promise.player)) promise.fulfilled = true; - continue; - } - if (!same || !revivalPromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise])) { + const isForce = (promise: RevivalPromise | LeaderRevivalPromise): promise is RevivalPromise => 'counter' in promise; + const same = (promise: RevivalPromise | LeaderRevivalPromise) => + g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && g.turn === promise.turn; + for (const promise of pending.filter(promise => !promise.answer)) + if (!same(promise) || g.revivalOrder && !g.revivalOrder.remaining.includes(promise.player)) promise.fulfilled = true; + const force: RevivalPromise[] = pending.filter(isForce).filter(promise => !promise.answer && !promise.fulfilled); + const leaders: LeaderRevivalPromise[] = pending.filter(promise => !isForce(promise) && !promise.answer && !promise.fulfilled) as LeaderRevivalPromise[]; + const sequence = g.revivalPromiseSequence ?? []; + const positives = pending.filter(promise => promise.answer).sort((a,b) => sequence.indexOf(a.event) - sequence.indexOf(b.event)); + for (const promise of positives) { + const trial = { ...g, revivalPromises: isForce(promise) ? [...force, promise] : force, + leaderRevivalPromises: isForce(promise) ? leaders : [...leaders, promise] }; + if (!same(promise) || !revivalPromiseCompletion(trial, getPlayer(trial, promise.player))) { requireRule(voluntary?.actor !== promise.player, - 'You cannot voluntarily evade your Truthtrance revival answer.'); + 'You cannot voluntarily evade your Truthtrance Revival answer.'); promise.released = true; - log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance revival answer; that answer is no longer binding.`); - } else kept.push(promise); + log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance ${isForce(promise) ? 'force' : 'named leader'}-revival answer; that answer is no longer binding.`); + } else if (isForce(promise)) force.push(promise); + else leaders.push(promise); } } -function projectedRevivalPromiseGuide(g: Game, player: string): { phase: number; action: Action } | null { +function projectedTypedRevivalPromiseGuide(g: Game, player: string, namedLeader: boolean): { phase: number; action: Action } | null { const ownPayment = g.phase === 3 && !g.response && !g.phaseOpening && g.decision?.kind === 'auctionPayment' && g.decision.player === player; if ((!revivalQuestionWindow(g) && !ownPayment) || g.truthtrance || !revivalOpportunity(g, player) || g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard || @@ -30247,8 +30487,10 @@ function projectedRevivalPromiseGuide(g: Game, player: string): { phase: number; // cannot evade it merely because Revival comes later in this turn. const lot = g.phase === 3 && !ownPayment && !g.richeseAuction && !g.ixAuction ? bidOpportunity(g) : null; if (lot && bidRequired(g.bidPromises ?? [], player, lot)) return null; - const promise = liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0]; + const named = liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player, g.turn); + const promise = namedLeader ? named[0] : !named.length ? liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0] : null; if (!promise) return null; + const event = namedLeader ? { leaderRevivalPromiseEvent: promise.event } : { revivalPromiseEvent: promise.event }; const plan = revivalPromisePlan(g, getPlayer(g, player)); if (!plan) return null; if (g.phase === 3 && g.richeseAuction && !ownPayment) { @@ -30256,7 +30498,7 @@ function projectedRevivalPromiseGuide(g: Game, player: string): { phase: number; const available = !native.outcome && native.eligible.includes(player) && (native.method === 'silent' ? !Object.hasOwn(native.sealed, player) : native.active === player); return available ? { phase: 3, action: { type: 'richeseBid', event: native.event, - amount: native.method === 'silent' ? 0 : null, allyPayment: 0, revivalPromiseEvent: promise.event } } : null; + amount: native.method === 'silent' ? 0 : null, allyPayment: 0, ...event } } : null; } if (g.phase === 3 && g.ixAuction) return null; const current = plan.early.find(step => step.phase === g.phase)?.action; @@ -30264,14 +30506,21 @@ function projectedRevivalPromiseGuide(g: Game, player: string): { phase: number; ? current ?? { type: 'ready' } : g.phase === 3 ? ownPayment ? current?.type === 'decision' ? current : { type: 'decision', karama: false } : g.auction?.active === player ? { type: 'passBid' } : null : null; - return action ? { phase: g.phase, action: { ...action, revivalPromiseEvent: promise.event } } : null; + return action ? { phase: g.phase, action: { ...action, ...event } } : null; +} +function projectedRevivalPromiseGuide(g: Game, player: string) { + return projectedTypedRevivalPromiseGuide(g, player, false); +} +function projectedLeaderRevivalPromiseGuide(g: Game, player: string) { + return projectedTypedRevivalPromiseGuide(g, player, true); } function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: PendingRevival) { const live = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn); if (action.revivalPromiseEvent !== undefined) requireRule(revivalOpportunity(g, p.id) && typeof action.revivalPromiseEvent === 'string' && live.some(promise => promise.event === action.revivalPromiseEvent), 'This revival commitment guide is stale.'); - if (!live.length) return; + if (!live.length && !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length && + !liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return; if (!revival) { requireRule(revivalPromisesSatisfied(g.revivalPromises ?? [], p.id, g.turn, { amount: 0, elite: 0 }), 'Complete the force returns required by your Truthtrance Yes before finishing Revival.'); @@ -30281,14 +30530,10 @@ function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: Pend // completed real return below records progress; an opposing stop records none. const trial = structuredClone(g), actor = getPlayer(trial, p.id); trial.truthtrance = null; - trial.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(trial.revivalPromises ?? [], p.id, - g.turn, { amount: revival.amount!, elite: revival.elite ?? 0 })); - // Legacy concurrent Ready does not forbid a later native request. Its No - // stays binding to phase close; only the detached completion probe reopens. + // Legacy concurrent Ready may reopen; quoted progress remains detached. if (!trial.revivalOrder) trial.ready = trial.ready.filter(player => player !== p.id); - actor.spice -= revival.cost; - commitRevivalForcePieces(trial, actor, revival); - requireRule(!!revivalPromiseCompletion(trial, actor), + stagePromisedForceReturn(trial, actor, revival); + requireRule(!!revivalPromiseCompletion(trial, actor) && bribeCompatibleLaterPromises(trial, actor, {}), 'This request would prevent you from completing your Truthtrance revival answer.'); } function beginRevival(g: Game, revival: PendingRevival) { @@ -35216,6 +35461,12 @@ export function applyAction(state: Game, id: string, action: Action): Game { Object.keys(guide.action).every(key => action[key] === guide.action[key]), 'This bribe commitment guide is stale or belongs to another payment.'); } + if (action.leaderRevivalPromiseEvent !== undefined) { + const guide = projectedLeaderRevivalPromiseGuide(state, id); + requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && + Object.keys(guide.action).every(key => action[key] === guide.action[key]), + 'This named leader-revival commitment guide is stale or belongs to another opportunity.'); + } if (action.revivalPromiseEvent !== undefined) { const guide = projectedRevivalPromiseGuide(state, id); requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && @@ -35908,6 +36159,8 @@ function applyActionInner( bindAlliance: bindAllianceTruth, bribeAnswers: bribeTruthAnswers, bindBribe: bindBribeTruth, + leaderRevivalAnswers: leaderRevivalTruthAnswers, + bindLeaderRevival: bindLeaderRevivalTruth, revivalAnswers: revivalTruthAnswers, bindRevival: bindRevivalTruth, bidAnswers: bidTruthAnswers, @@ -35921,7 +36174,7 @@ function applyActionInner( ) return g; } catch (error) { - if (error instanceof TruthError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) + if (error instanceof TruthError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; } @@ -38465,7 +38718,9 @@ function applyActionInner( 'Not enough spice for these extra revivals.', ); if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || - liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) { + liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) || + liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer)) { // A later responder cannot erase the owner's voluntary gift/revival // invoice. Check its allowed outcome before opening the response. const afterPayment = structuredClone(g); @@ -38473,6 +38728,7 @@ function applyActionInner( ? amount : paidForceRevivalCost(recipient, amount, elite); reconcileShipmentPromises(afterPayment, { actor: p.id, action }); reconcileGroundMovementPromises(afterPayment, p.id, action, g.phase); + reconcileRevivalPromises(afterPayment, { actor: p.id, action }); } g.response = { kind: t, @@ -38560,62 +38816,14 @@ function applyActionInner( } return g; } - requireRule( - p.id !== tleilaxu.id && !p.leaderRevived, - 'Only one negotiated leader revival is permitted per faction per phase.', - ); - const requested = - t === 'requestLeaderRevival' - ? stringField(action.leader) - : g.revivalRequests[id]?.leader; - requireRule(requested, 'Request a leader first.'); - requireRule( - !revivalPrevented(g, p.id), - 'Tleilaxu prevented this faction’s normal revivals for this turn.', - ); - const buyback = p.leaders.some( - (l) => l.id === requested && l.gholaBy === tleilaxu.id, - ); - requireRule( - buyback || p.leaders.some(nativeAvailable), - 'When all native leaders are unavailable, use ordinary revival rules.', - ); - if (requested === 'kwisatz') - requireRule( - g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, - 'Kwisatz Haderach is not in the tanks.', - ); - else - requireRule( - p.leaders.some((l) => l.id === requested && l.dead && !l.capturedBy), - 'Choose one of your dead native leaders.', - ); - requireRule( - !g.revivalRules?.earlyBlocked.includes(`${p.id}:${requested}`), - 'Early revival of that leader was canceled this phase.', - ); - if (t === 'requestLeaderRevival') + if (t === 'requestLeaderRevival') { + const requested = stringField(action.leader); + personalLeaderRequestQuote(g, p, requested); g.revivalRequests[id] = { leader: requested, price: null }; - else { - const price = g.revivalRequests[id]?.price; - requireRule( - !g.revivalRequests[id]?.declined, - 'That request was declined.', - ); - requireRule( - typeof price === 'number', - 'Wait for Tleilaxu to quote a price.', - ); - requireRule(p.spice >= price, 'Not enough spice for the agreed revival.'); - beginRevival(g, { - player: id, - kind: requested === 'kwisatz' ? 'kwisatz' : 'leader', - leader: requested, - normalCost: price, - cost: price, - free: 0, - checks: ['earlyRevival'], - }); + } else { + const quote = negotiatedLeaderRevivalQuote(g, p); + if (execution === 'live') guardPromisedLeaderRevival(g, p, action, quote); + beginRevival(g, quote.revival); } return g; } @@ -38652,7 +38860,7 @@ function applyActionInner( const normalCost = leader.strength; const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost; requireRule(p.spice >= cost, 'Not enough spice for this ghola.'); - beginRevival(g, { + const revival: PendingRevival = { player: id, kind: 'foreignGhola', leader: leader.id, @@ -38663,7 +38871,9 @@ function applyActionInner( cost < normalCost ? ['foreignGhola', 'revivalDiscount'] : ['foreignGhola'], - }); + }; + if (execution === 'live') guardPromisedLeaderRevival(g, p, action, { revival, cycle: null }); + beginRevival(g, revival); return g; } if (t === 'revive') { @@ -38675,71 +38885,14 @@ function applyActionInner( return g; } if (t === 'reviveKwisatz' || t === 'reviveLeader') { - requireRule( - g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'), - 'Only one leader revival is permitted this phase.', - ); - if (execution === 'live') startNativeRevivalOrder(g,id); - requireRule( - !revivalPrevented(g, p.id), - 'Tleilaxu prevented this faction’s normal revivals for this turn.', - ); - const options = leaderRevivalOptions(g, p); - const duke = t === 'reviveLeader' && action.leader === DUKE_VIDAL_ID; - if (duke) { - requireRule(p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal.'); - const blocked = ecazDukeRevivalBlock(g, p.id); - requireRule(!blocked, blocked ?? 'Duke Vidal is unavailable for revival.'); + const quote = normalLeaderRevivalQuote(g, p, action); + if (execution === 'live') { + startNativeRevivalOrder(g, id); + guardPromisedLeaderRevival(g, p, action, quote); } - const dukeQuote = duke ? quoteEcazDukeRevival(g, id, !!revivalDiscount(g, p)) : null; - const l = t === 'reviveLeader' - ? duke ? g.dukeVidal!.leader : p.leaders.find((l) => l.id === action.leader) - : undefined; - if (t === 'reviveKwisatz') - requireRule( - g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, - 'Kwisatz Haderach is not in the tanks.', - ); - else - requireRule( - l?.dead && !l.capturedBy && !l.gholaBy, - 'Choose a dead leader.', - ); - const option = options.leaders.find((option) => option.id === l?.id); - const normal = - t === 'reviveKwisatz' ? !!options.kwisatz : !!option && !option.early; - requireRule( - normal || p.faction === 'tleilaxu', - 'All leaders must die before a new revival cycle can begin.', - ); - const checks: PendingRevival['checks'] = []; - if (!normal) { - requireRule( - !g.revivalRules?.earlyBlocked.includes(`${id}:${l!.id}`), - 'Early revival of that leader was canceled this phase.', - ); - checks.push('earlyRevival'); - } - requireRule( - t === 'reviveKwisatz' ? !!options.kwisatz : !!option, - 'That leader is unavailable for revival this phase.', - ); - const normalCost = dukeQuote?.normalCost ?? l?.strength ?? 2; - const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost); - requireRule(p.spice >= cost, 'Not enough spice.'); - if (cost < normalCost) checks.push('revivalDiscount'); // Ecaz records its physical cohort only after the return actually commits. - if (p.faction !== 'ecaz' && (!duke || options.cycle > p.revivalCycle)) - p.revivalCycle = options.cycle; - beginRevival(g, { - player: id, - kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader', - leader: l?.id, - normalCost, - cost, - free: 0, - checks, - }); + if (quote.cycle !== null) p.revivalCycle = quote.cycle; + beginRevival(g, quote.revival); return g; } if (t === 'revealNoField') { @@ -39597,6 +39750,14 @@ function applyActionInner( if (cunning) { cunning.hajrPlays = p.hajrPlays; saveGuildCunning(g,cunning); } } else if (c.effect === 'ghola') { applyGholaEffect(g, p, action, c.id); + if (execution === 'live' && (liveRevivalPromises(g.revivalPromises ?? [], id, g.turn).some(promise => promise.answer) || + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], id, g.turn).some(promise => promise.answer))) { + // The voluntary physical return must not evade an ordinary-return Yes + // by opening a later income response owned by a different actor. + const trial = structuredClone(g); + trial.truthtrance = null; trial.response = null; + reconcileRevivalPromises(trial, { actor: id, action }); + } } else if (c.effect === 'harvester') { const blow = g.spiceWindow!; if (blow.sector !== g.storm) { @@ -39637,7 +39798,7 @@ function applyActionInner( if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p); if (g.phase === 7) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer), 'Complete your Truthtrance bribe payments before finishing Collection.'); - if (g.phase === 4) guardPromisedRevival(g, p, action); + if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); } requireRule( [1, 2, 4, 7, 8].includes(g.phase) || (g.phase === 0 && g.stormPending !== null), @@ -39850,6 +40011,11 @@ export function viewGame(state: Game, id: string) { g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null, revivalPromises: (g.revivalPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })), revivalPromiseGuide: projectedRevivalPromiseGuide(g, id), + truthLeaderRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [], + truthLeaderRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'leaderRevival' ? leaderRevivalTruthAnswers(g, me, g.truthtrance.question) : null, + leaderRevivalPromises: (g.leaderRevivalPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })), + leaderRevivalPromiseGuide: projectedLeaderRevivalPromiseGuide(g, id), bidPromises: projectedBidPromises(g, id), bidPromiseGuide: projectedBidPromiseGuide(g, id), truthtrance: g.truthtrance ?? null, diff --git a/game/leader-revival-promises.ts b/game/leader-revival-promises.ts new file mode 100644 index 00000000..3e996813 --- /dev/null +++ b/game/leader-revival-promises.ts @@ -0,0 +1,90 @@ +export { revivalPromiseModeSupported as leaderRevivalPromiseModeSupported } from './revival-promises'; + +export type LeaderRevivalClaim = { turn: number; leader: string }; +export type LeaderRevivalPromise = LeaderRevivalClaim & { + event: string; + player: string; + asker: string; + answer: boolean; + fulfilled?: boolean; + released?: boolean; +}; + +export class LeaderRevivalPromiseError extends Error { + constructor(message: string) { + super(message); + this.name = 'LeaderRevivalPromiseError'; + } +} + +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value === value.trim(); +const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean'; + +export function parseLeaderRevivalClaim(value: unknown): LeaderRevivalClaim { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new LeaderRevivalPromiseError('Choose a current-turn named leader-revival question.'); + const input = value as Record; + if (typeof input.turn !== 'number' || !Number.isSafeInteger(input.turn) || input.turn < 1 || !identifier(input.leader)) + throw new LeaderRevivalPromiseError('Choose the current turn and exact leader identity.'); + return { turn: input.turn, leader: input.leader }; +} + +export function leaderRevivalClaimText(claim: LeaderRevivalClaim, + leaderName: (id: string) => string = id => id === 'kwisatz' ? 'Kwisatz Haderach' : id): string { + return `revive ${leaderName(claim.leader)} through ordinary leader revival from now until your Revival opportunity ends this turn`; +} + +export function liveLeaderRevivalPromises(promises: readonly LeaderRevivalPromise[], player: string, + turn: number): LeaderRevivalPromise[] { + return promises.filter(promise => promise.player === player && promise.turn === turn && + !promise.fulfilled && !promise.released); +} + +/** Distinct required identities are not a grant of additional native slots. + * The engine's joint Revival quote owns allowance, custody, price and order. */ +export function leaderRevivalRequirements(promises: readonly LeaderRevivalPromise[], player: string, + turn: number): { required: string[]; forbidden: string[] } | null { + const required = new Set(), forbidden = new Set(); + for (const promise of liveLeaderRevivalPromises(promises, player, turn)) + (promise.answer ? required : forbidden).add(promise.leader); + if ([...required].some(leader => forbidden.has(leader))) return null; + return { required: [...required], forbidden: [...forbidden] }; +} + +/** Proposed final future ordinary returns, not currently living leaders or + * historical deaths. No remains binding across other nonmatching returns. */ +export function leaderRevivalPromisesSatisfied(promises: readonly LeaderRevivalPromise[], player: string, + turn: number, returned: readonly string[]): boolean { + return liveLeaderRevivalPromises(promises, player, turn) + .every(promise => returned.includes(promise.leader) === promise.answer); +} + +/** Provisional named future-return composition. The actual ordinary-return + * caller owns source semantics, including the native KH token. Declaration, + * prevention, special/card returns and income do not count as this event. */ +export function recordPromisedLeaderRevival(promises: readonly LeaderRevivalPromise[], player: string, + turn: number, leader: string): LeaderRevivalPromise[] { + if (!identifier(leader)) throw new LeaderRevivalPromiseError('Retain the exact returned leader identity.'); + const matching = liveLeaderRevivalPromises(promises, player, turn).filter(promise => promise.leader === leader); + if (matching.some(promise => !promise.answer)) + throw new LeaderRevivalPromiseError('This ordinary leader revival conflicts with your Truthtrance No answer.'); + return promises.map(promise => matching.includes(promise) ? { ...promise, fulfilled: true } : promise); +} + +/** Identity eligibility and historical physical membership remain native + * rules. This validates the ledger's public event and actual participants. */ +export function validateLeaderRevivalPromises(promises: readonly LeaderRevivalPromise[], players: readonly string[]): void { + if (!Array.isArray(promises)) throw new LeaderRevivalPromiseError('The named leader-revival commitment record is invalid.'); + const events = new Set(); + for (const promise of promises) { + parseLeaderRevivalClaim(promise); + if (!identifier(promise.event) || events.has(promise.event) || + !identifier(promise.player) || !identifier(promise.asker) || + !players.includes(promise.player) || !players.includes(promise.asker) || promise.player === promise.asker || + typeof promise.answer !== 'boolean' || !optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) || + promise.fulfilled && promise.released) + throw new LeaderRevivalPromiseError('The named leader-revival commitment lost its event or participants.'); + events.add(promise.event); + } +} diff --git a/game/reference.ts b/game/reference.ts index a25b67df..8eadaf5f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3258,6 +3258,31 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] }, ], }, + { + id: 'truthtrance-leader-revival', + title: 'Truthtrance: named leader revival this turn', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Bind an actual future personal native return of a named leader or Kwisatz, retaining native cost, custody, cohort and allowance.', + steps: [ + 'Choose Bind a named leader revival this turn, the respondent and a public printed leader identity. Registered native Basic/Advanced tables support quiet phases0–4 with a remaining Revival opportunity. Advanced Atreides may name Kwisatz Haderach; Ecaz may name the shared Duke where present. Optional compositions remain unfinished.', + 'Only a future actual personal native return after this answer counts. Direct ordinary requests and actually accepted personal early negotiations share real return accounting. A request, quote, prior return, Ghola card, special return or foreign ghola does not fulfill Yes. No forbids the named native return without banning other legal actions.', + 'The original dead/captured/foreign custody, cohort, cost, native leader allowance and ordered ownership remain. Several same-identity Yes answers may share one actual return; different names need actual native slots. Force and leader commitments share one purse and completion plan.', + 'The owned guide may claim available Charity, preserve or pay a known auction liability, make a required force return, return a leader/Kwisatz or finish at the real native opportunity. Tleilaxu may prepare through its actual one-time self free-revival income. An already-priced, not-declined personal negotiation can be accepted; a future foreign agreement or price is never invented.', + 'Yes prevents voluntary spending, invoices or Finish that erase its last completion. A completed opposing effect can release impossible answers in shared force/leader acceptance order. The actual allowed return fulfills Yes; responses and payment/income keep their original timing. Basic No lasts to phase close; stamped Advanced No ends with the native opportunity.', + 'Your own persistent panel shows pending, fulfilled and released answers. Exact event/phase guides and all four minimal policies use the same legal native actions. Strategy and difficulty calibration remain deferred.', + 'This named future-return source, interval, owned witness and release policy are provisional online compositions of general current-turn Truthtrance authority. Dedicated original cohort, Auditor, Duke and Kwisatz commitment journeys, wider preparation/compositions, browser and complete-mode acceptance remain due. No start gate opens.', + ], + related: ['card-truthtrance', 'truthtrance-revival', 'advanced-revival-order', 'truthtrance-bribe', 'implementation-checklist', phaseRuleId(4)], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Named native future returns, joint force/leader purse and slots, real completion, source exclusions, owned preparation and accepted-order release retain original rules.', evidence: ['game/leader-revival-promises.ts', 'game/revival.ts', 'game/engine.ts', 'game/truthtrance.ts', 'tests/leader-revival-promises.test.ts', 'tests/truth-leader-revival-runtime.test.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Public-name fields/preview, target-only answers and persistent exact event/phase native guides.', evidence: ['components/leader-revival-claim-fields.tsx', 'components/leader-revival-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-leader-revival-controls.test.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Existing minimal profiles answer and use the same native guide. Full negotiation strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-leader-revival-runtime.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'General authority, native source/interval, owned preparation and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Original native Tleilaxu battle/death/return source, pure and controlled UI evidence; broader normal-cohort/KH/Duke, browser and deployed acceptance remain due.', evidence: ['tests/fixture-truth-leader-revival.ts', 'tests/truth-leader-revival-runtime.test.ts', 'tests/truth-leader-revival-controls.test.tsx'] }, + ], + }, { id: 'truthtrance-revival', title: 'Truthtrance: ordinary force-revival promises', @@ -3457,7 +3482,7 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', summary: - 'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', + 'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, named native leader-revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', steps: [ 'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.', 'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.', @@ -3471,6 +3496,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.', 'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.', 'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.', + 'A named leader-revival question binds an actual future personal native return before your Revival opportunity ends, including Kwisatz where eligible. Force/leader promises share costs and slots; prior/card/special/foreign-ghola returns do not count. See the named-leader guide for provisional scope and preparation.', 'An ordinary force-revival question binds cumulative future total, normal or elite returns in native Revival later this turn, from supported phases0–4. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.', 'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.', 'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.', @@ -3490,6 +3516,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-movement', 'truthtrance-bid', 'truthtrance-revival', + 'truthtrance-leader-revival', 'truthtrance-bribe', 'truthtrance-alliance', 'richese-no-field', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index f3206837..9820343c 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { parseLeaderRevivalClaim, leaderRevivalClaimText, leaderRevivalPromiseModeSupported, type LeaderRevivalClaim } from './leader-revival-promises'; import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises'; import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises'; import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises'; @@ -63,6 +64,7 @@ export type TruthFact = | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'and' | 'or'; terms: TruthFact[] }; export type TruthQuestion = + | ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim) | ({ kind: 'alliance'; target: string } & AllianceClaim) | ({ kind: 'bribe'; target: string } & BribeClaim) | ({ kind: 'revival'; target: string } & RevivalClaim) @@ -218,6 +220,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { g.players.some((p) => p.id === v.target), 'Ask one other player.', ); + if (v.kind === 'leaderRevival') { + check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn', + 'Supply the current turn, exact named leader and respondent.'); + const claim = parseLeaderRevivalClaim(v); + check(leaderRevivalPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && + !g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && + !g.pendingTreacheryDiscard && claim.turn === g.turn, + 'Choose a supported current-turn ordinary leader-revival question in a quiet window.'); + return { kind: 'leaderRevival', target: v.target, ...claim }; + } if (v.kind === 'alliance') { check(Object.keys(v).sort().join(',') === 'kind,partner,target,turn', 'Supply the current Nexus turn, named alliance partner and respondent.'); @@ -488,6 +500,7 @@ export function truthQuestionText( playerName: (id: string) => string = id => id, ): string { if (q.kind === 'freeform') return q.text; + if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`; if (q.kind === 'alliance') return `Will you ${allianceClaimText(q, playerName)}?`; if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`; if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`; @@ -541,6 +554,8 @@ export function resolveTruthAction( bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void; bribeAnswers?: (g: Game, p: Player, claim: BribeClaim) => TruthAnswer[]; bindBribe?: (g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) => void; + leaderRevivalAnswers?: (g: Game, p: Player, claim: LeaderRevivalClaim) => TruthAnswer[]; + bindLeaderRevival?: (g: Game, p: Player, claim: LeaderRevivalClaim, answer: boolean, asker: string) => void; revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[]; bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void; bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[]; @@ -663,6 +678,9 @@ export function resolveTruthAction( 'Waiting for the Truthtrance holder to ask a question.', ); w.question = parseQuestion(g, p.id, a.question); + if (w.question.kind === 'leaderRevival') + check(effects.leaderRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a named leader question with a compatible native completion.'); if (w.question.kind === 'alliance') check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a current Nexus question with a compatible answer.'); @@ -725,6 +743,11 @@ export function resolveTruthAction( 'Choose an answer consistent with legal ground movement and earlier promises.'); if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === 'leaderRevival') { + check(effects.leaderRevivalAnswers && effects.bindLeaderRevival && effects.leaderRevivalAnswers(g, p, q).includes(a.answer), + 'Choose a named leader-revival answer compatible with native eligibility and earlier promises.'); + if (a.answer !== 'unknown') effects.bindLeaderRevival(g, p, q, a.answer === 'yes', current.player); + } if (q.kind === 'alliance') { check(effects.allianceAnswers && effects.bindAlliance && effects.allianceAnswers(g, p, q).includes(a.answer), 'Choose an answer consistent with actual alliance consent and earlier commitments.'); diff --git a/tests/fixture-truth-leader-revival.ts b/tests/fixture-truth-leader-revival.ts new file mode 100644 index 00000000..c74ca552 --- /dev/null +++ b/tests/fixture-truth-leader-revival.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { leaderRevivalOptions } from '../game/revival'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { actTruthBid, truthBidInventory, truthBidPlayer } from './fixture-truth-bid'; + +export type TruthLeaderRevivalStep = { actor: string; action: Action }; +export type TruthLeaderRevivalTransition = { before: Game; steps: TruthLeaderRevivalStep[]; after: Game }; +export const actTruthLeaderRevival = actTruthBid; +export const truthLeaderRevivalInventory = truthBidInventory; +export const truthLeaderRevivalPlayer = truthBidPlayer; +export const truthLeaderRevivalClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival; + +export function nextTruthLeaderRevival(game: Game): TruthLeaderRevivalStep | null { + if (game.truthtrance) return null; + if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) { + if (game.decision?.kind === 'faceDance') return { actor: game.decision.player, action: { type: 'decision', reveal: false } }; + if (game.decision?.kind === 'revivalStop') return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + if (game.battle?.preparation) return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }; + } + for (const player of game.players) { + const view = viewGame(game, player.id); + if (view.leaderRevivalPromiseGuide?.phase === game.phase) + return { actor: player.id, action: view.leaderRevivalPromiseGuide.action }; + if (view.revivalPromiseGuide?.phase === game.phase) return { actor: player.id, action: view.revivalPromiseGuide.action }; + if (view.bidPromiseGuide) return { actor: player.id, action: view.bidPromiseGuide.action }; + } + return nextRicheseStrongholdsNativeStep(game); +} +export function advanceTruthLeaderRevival(game: Game, until: (state: Game) => boolean, + actions: TruthLeaderRevivalStep[] = []): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The real named-leader source ended before its owned control'); + const next = nextTruthLeaderRevival(game); assert.ok(next, 'The original named-leader question and answer belong to their callers'); + game = actTruthLeaderRevival(game, next.actor, next.action, actions); + } + throw Error('The real named-leader source did not reach its finite boundary'); +} +export function settleTruthLeaderRevival(game: Game, actions: TruthLeaderRevivalStep[] = []): Game { + return advanceTruthLeaderRevival(game, truthLeaderRevivalClean, actions); +} +export function finishTruthLeaderRevival(game: Game, actions: TruthLeaderRevivalStep[] = []): Game { + const turn = game.turn; + return advanceTruthLeaderRevival(game, state => state.turn === turn && state.phase === 5 && truthLeaderRevivalClean(state), actions); +} + +/** Public original H/A/T +Ix47 with no optional modules. Harkonnen really + * receives both Truthtrances, A a projectile, T Worthless/Ghola or the optional + * actually played Tooth that also kills A's named native disc. + * T pays1 to enter original Arrakeen10, loses the counter and Hidar in an + * actual projectile battle, then reaches genuine next-turn Revival. This is + * native T own early permission, not an ordinary five-disc cohort opening. */ +export function createTruthLeaderRevivalFixture(options: { + advanced?: boolean; window?: 'active' | 'waiting'; ghola?: boolean; bothDead?: boolean; +} = {}) { + const advanced = options.advanced ?? false, window = options.window ?? 'active'; + assert.ok(!options.ghola || !options.bothDead, 'One actual starting card supplies either Ghola or the native Tooth'); + const asker = 'harkonnen', opponent = 'atreides', target = 'tleilaxu', leader = 'tleilaxu-1'; + let game = createGame('TRUTHLEADERREVIVALSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix']); + joinGame(game, newPlayer(opponent, 'Atreides', 'atreides')); + joinGame(game, newPlayer(target, 'Tleilaxu', 'tleilaxu')); + const initial = structuredClone(game), actions: TruthLeaderRevivalStep[] = []; + const act = (actor: string, action: Action) => { game = actTruthLeaderRevival(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceTruthLeaderRevival(game, until, actions); }; + const transition = (run: () => void): TruthLeaderRevivalTransition => { + const before = structuredClone(game), from = actions.length; run(); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(asker, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); assert.equal(game.status, 'setup'); + assert.ok(game.players.every(player => !player.hand.length)); + const originalCards = truthLeaderRevivalInventory(game); assert.equal(originalCards.length, 47); + const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2); + const weapon = game.deck.find(card => card.kind === 'projectile'); + const targetCard = game.deck.find(card => options.bothDead ? card.kind === 'poisonTooth' : + options.ghola ? card.effect === 'ghola' : card.kind === 'worthless'); + assert.ok(weapon && targetCard); + const deal = [truths[0].id, truths[1].id, weapon.id, targetCard.id]; + const dealOrder = deal.map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + assert.deepEqual(truthLeaderRevivalPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id)); + assert.ok(truthLeaderRevivalPlayer(game, opponent).hand.some(card => card.id === weapon.id)); + assert.ok(truthLeaderRevivalPlayer(game, target).hand.some(card => card.id === targetCard.id)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder = [0, 1, 2, 3].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && card.sector >= 7); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + return { position, from, card: structuredClone(card) }; + }); + reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthLeaderRevivalClean(state)); + const shipmentAction: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 }; + const shipment = transition(() => { act(target, shipmentAction); reach(truthLeaderRevivalClean); }); + assert.equal(truthLeaderRevivalPlayer(game, target).spice, 4); + reach(state => state.phase === 6 && truthLeaderRevivalClean(state) && !!state.active); + const beforeBattle = structuredClone(game); + const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'arrakeen'); assert.ok(choice); + const battleAction: Action = { type: 'chooseBattle', territory: 'arrakeen', + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }; + act(choice.chooser, battleAction); + reach(state => !!state.battle && truthLeaderRevivalClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false); + const beforePlans = structuredClone(game); + const winnerLeader = truthLeaderRevivalPlayer(game, opponent).leaders.find(leader => leader.id === 'atreides-4' && !leader.dead); assert.ok(winnerLeader); + const plans: TruthLeaderRevivalStep[] = [ + { actor: target, action: { type: 'battlePlan', leader, dial: 0, support: 0, weapon: options.bothDead ? targetCard.id : null, defense: null } }, + { actor: opponent, action: { type: 'battlePlan', leader: winnerLeader.id, dial: options.bothDead ? 1 : 0, + support: advanced && options.bothDead ? 1 : 0, weapon: weapon.id, defense: null } }, + ]; + for (const plan of plans) act(plan.actor, plan.action); + const revealed = structuredClone(game); + const battle = transition(() => { reach(state => !state.battle && truthLeaderRevivalClean(state)); }); + const afterBattle = structuredClone(game), dead = truthLeaderRevivalPlayer(game, target).leaders.find(disc => disc.id === leader)!; + assert.equal(dead.dead, true); assert.equal(dead.deaths, 1); + assert.equal(truthLeaderRevivalPlayer(game, target).leaders.filter(disc => !disc.dead).length, 4); + assert.equal(game.lastBattleContext?.winner, opponent); + assert.equal(truthLeaderRevivalPlayer(game, target).tanks, 1); + assert.equal(truthLeaderRevivalPlayer(game, target).reserves, 19); + if (options.bothDead) assert.equal(truthLeaderRevivalPlayer(game, opponent).leaders.find(disc => disc.id === winnerLeader.id)!.dead, true); + const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1 && truthLeaderRevivalClean(state)); }); + const postStormActionCount = actions.length, postStorm = structuredClone(game); + const bidding = transition(() => { reach(state => state.turn === 2 && state.phase === 3 && truthLeaderRevivalClean(state)); }); + const phase3ActionCount = actions.length, phase3 = structuredClone(game); + const arrival = transition(() => { reach(state => state.turn === 2 && state.phase === 4 && truthLeaderRevivalClean(state)); }); + const opened = structuredClone(game); + if (window === 'active' && game.revivalOrder) { + assert.ok(game.revivalOrder.remaining.includes(target)); + reach(state => state.phase === 4 && truthLeaderRevivalClean(state) && state.revivalOrder?.remaining[0] === target); + } + const own = truthLeaderRevivalPlayer(game, target), quote = leaderRevivalOptions(game, own).leaders.find(option => option.id === leader); assert.ok(quote); + assert.equal(quote.early, true); assert.equal(quote.cost, 2); assert.equal(quote.affordable, true); + assert.equal(own.spice, 4); assert.equal(own.revivalCycle, 0); assert.equal(own.leaderRevived, false); + assert.deepEqual(truthLeaderRevivalInventory(game), originalCards); + const question = { kind: 'leaderRevival' as const, target, turn: game.turn, leader }; + return { game, sourceGame: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup, + advanced, window, bothDead: options.bothDead ?? false, asker, opponent, target, opponentLeader: winnerLeader.id, + actor: asker, leader, leaderName: dead.name, + truths: truths.map(card => card.id), truth: truths[0].id, card: truths[0].id, + ghola: options.ghola ? targetCard.id : null, weapon: weapon.id, + shipmentAction, shipment, beforeBattle, battleAction, beforePlans, plans, revealed, battle, afterBattle, + storm, postStorm, postStormActionCount, bidding, phase3, phase3ActionCount, arrival, opened, quote, + question, declarationAction: { type: 'card', card: truths[0].id } as Action, + questionAction: { type: 'truthAsk', question } as Action, revivalAction: { type: 'reviveLeader', leader } as Action, + originalCards, originalInventory: originalCards, originalSpice, deal, dealOrder, spiceOrder, + actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, sourceCount: actions.length, + staging: [] as string[], sourceDisclosure: 'Original public H/A/T Ix47 Basic/Advanced setup deals H both Truthtrances, A projectile and T Worthless/Ghola/PoisonTooth through unopened positions only. Real T1 shipment pays1; actual Hidar dies to the original A projectile and loses its one counter. The optional actual Tooth kills A leader too; A then dials1 with original Advanced support1 to retain a real win. Native no-Traitor calls and any real FaceDance decline preserve the result. Actual next Storm/phases reach T4spice and native own early leader cost2 with four living discs; this does not open the ordinary five-disc cohort. No played hand, army, wallet, death, clock or promise is installed.' }; +} +export type TruthLeaderRevivalFixture = ReturnType; +export function declareTruthLeaderRevival(f: TruthLeaderRevivalFixture, state = f.game, options: { copies?: 1 | 2 } = {}) { + let game = state; const before = structuredClone(game), actions: TruthLeaderRevivalStep[] = []; + const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) }; + game = actTruthLeaderRevival(game, f.asker, declaration, actions); + for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) { + const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor); + game = actTruthLeaderRevival(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return { game, before, declaration, actions, actionCount: actions.length }; +} +export function askTruthLeaderRevival(game: Game, asker: string, target: string, leader: string, + actions: TruthLeaderRevivalStep[] = []): Game { + return actTruthLeaderRevival(game, asker, { type: 'truthAsk', question: { kind: 'leaderRevival', target, turn: game.turn, leader } }, actions); +} +export function answerTruthLeaderRevival(game: Game, target: string, answer: 'yes' | 'no', + actions: TruthLeaderRevivalStep[] = []): Game { + return actTruthLeaderRevival(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthLeaderRevival(f: TruthLeaderRevivalFixture, answer: 'yes' | 'no' = 'yes', leader = f.leader, state = f.game) { + const declared = declareTruthLeaderRevival(f, state), actions = [...declared.actions]; + const asked = askTruthLeaderRevival(declared.game, f.asker, f.target, leader, actions); + const game = answerTruthLeaderRevival(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} diff --git a/tests/leader-revival-promises.test.ts b/tests/leader-revival-promises.test.ts new file mode 100644 index 00000000..ebd584b3 --- /dev/null +++ b/tests/leader-revival-promises.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + LeaderRevivalPromiseError, + leaderRevivalClaimText, + leaderRevivalPromiseModeSupported, + leaderRevivalPromisesSatisfied, + leaderRevivalRequirements, + liveLeaderRevivalPromises, + parseLeaderRevivalClaim, + recordPromisedLeaderRevival, + validateLeaderRevivalPromises, + type LeaderRevivalPromise, +} from '../game/leader-revival-promises'; +import { revivalPromiseModeSupported } from '../game/revival-promises'; + +const seats = ['respondent', 'asker', 'other-player'] as const; +function promise(event: string, leader = 'leader-a', extra: Partial = {}): LeaderRevivalPromise { + return { event, turn: 2, leader, player: 'respondent', asker: 'asker', answer: true, ...extra }; +} + +void test('named leader claims canonicalize current turn and exact identity including the reserved native KH token', () => { + const input = { turn: 2, leader: 'leader-a', currentDeath: 1, privateCost: 3 }; + assert.deepEqual(parseLeaderRevivalClaim(input), { turn: 2, leader: 'leader-a' }); + assert.deepEqual(parseLeaderRevivalClaim({ turn: Number.MAX_SAFE_INTEGER, leader: 'kwisatz' }), + { turn: Number.MAX_SAFE_INTEGER, leader: 'kwisatz' }); + for (const turn of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined]) + assert.throws(() => parseLeaderRevivalClaim({ ...input, turn }), LeaderRevivalPromiseError); + for (const leader of ['', ' ', ' leader-a', 'leader-a ', 1, null, undefined]) + assert.throws(() => parseLeaderRevivalClaim({ ...input, leader }), LeaderRevivalPromiseError); + for (const value of [null, undefined, [], 'leader-a', 2]) + assert.throws(() => parseLeaderRevivalClaim(value), LeaderRevivalPromiseError); + assert.match(leaderRevivalClaimText(parseLeaderRevivalClaim(input), () => 'Named Leader'), /revive Named Leader through ordinary leader revival/); + assert.match(leaderRevivalClaimText({ turn: 2, leader: 'kwisatz' }), /revive Kwisatz Haderach/); + assert.match(leaderRevivalClaimText(parseLeaderRevivalClaim(input)), /from now until your Revival opportunity ends this turn/); +}); + +void test('named leader scope is the existing force-revival mode alias and introduces no optional-module permission', () => { + assert.equal(leaderRevivalPromiseModeSupported, revivalPromiseModeSupported); + const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] }; + const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'], + players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) }; + assert.equal(leaderRevivalPromiseModeSupported(classic), true); + assert.equal(leaderRevivalPromiseModeSupported({ ...classic, advanced: true }), true); + assert.equal(leaderRevivalPromiseModeSupported(native), true); + for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards', + 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery', + 'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const) + assert.equal(leaderRevivalPromiseModeSupported({ ...native, [option]: true }), false, option); + assert.equal(leaderRevivalPromiseModeSupported({ ...native, expansions: ['ix', 'choam'] }), false); +}); + +void test('joint requirements deduplicate the same disc and retain distinct leaders for native allowance quotation', () => { + const records = [promise('yes-a'), promise('yes-a-again', 'leader-a', { asker: 'other-player' }), + promise('yes-b', 'leader-b'), promise('no-c', 'leader-c', { answer: false }), + promise('no-c-again', 'leader-c', { answer: false })]; + assert.deepEqual(leaderRevivalRequirements(records, 'respondent', 2), + { required: ['leader-a', 'leader-b'], forbidden: ['leader-c'] }); + assert.equal(leaderRevivalPromisesSatisfied(records, 'respondent', 2, ['leader-a', 'leader-b']), true); + assert.equal(leaderRevivalPromisesSatisfied(records, 'respondent', 2, ['leader-a']), false); + assert.equal(leaderRevivalPromisesSatisfied(records, 'respondent', 2, ['leader-a', 'leader-b', 'leader-c']), false); + assert.equal(leaderRevivalRequirements([...records, promise('no-a', 'leader-a', { answer: false })], 'respondent', 2), null); + assert.equal(leaderRevivalPromisesSatisfied([promise('yes-a'), promise('no-a', 'leader-a', { answer: false })], + 'respondent', 2, ['leader-a']), false); +}); + +void test('hypothetical completion matches exact future return identities and No allows other leaders or no return', () => { + const yes = [promise('yes-a')], no = [promise('no-a', 'leader-a', { answer: false })]; + assert.equal(leaderRevivalPromisesSatisfied(yes, 'respondent', 2, []), false); + assert.equal(leaderRevivalPromisesSatisfied(yes, 'respondent', 2, ['leader-b']), false); + assert.equal(leaderRevivalPromisesSatisfied(yes, 'respondent', 2, ['leader-a']), true); + assert.equal(leaderRevivalPromisesSatisfied(yes, 'respondent', 2, ['leader-a', 'leader-a']), true); + assert.equal(leaderRevivalPromisesSatisfied(no, 'respondent', 2, []), true); + assert.equal(leaderRevivalPromisesSatisfied(no, 'respondent', 2, ['leader-b']), true); + assert.equal(leaderRevivalPromisesSatisfied(no, 'respondent', 2, ['leader-a']), false); + assert.deepEqual(leaderRevivalRequirements(no, 'respondent', 2), { required: [], forbidden: ['leader-a'] }); + assert.equal(leaderRevivalPromisesSatisfied([], 'respondent', 2, []), true); +}); + +void test('one actual ordinary return fulfills every matching Yes while retaining other live clauses', () => { + const records = [promise('yes-a'), promise('yes-a-again'), promise('yes-b', 'leader-b'), + promise('no-c', 'leader-c', { answer: false })]; + const before = structuredClone(records); records.forEach(Object.freeze); Object.freeze(records); + const returned = recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-a'); + assert.notEqual(returned, records); assert.notEqual(returned[0], records[0]); assert.notEqual(returned[1], records[1]); + assert.equal(returned[0].fulfilled, true); assert.equal(returned[1].fulfilled, true); + assert.equal(returned[2], records[2]); assert.equal(returned[3], records[3]); + assert.deepEqual(leaderRevivalRequirements(returned, 'respondent', 2), { required: ['leader-b'], forbidden: ['leader-c'] }); + assert.deepEqual(records, before); +}); + +void test('a conflicting No rejects before any matching Yes can change and remains binding after unrelated returns', () => { + const records = [promise('yes-a'), promise('no-a', 'leader-a', { answer: false })]; + const before = structuredClone(records); records.forEach(Object.freeze); Object.freeze(records); + assert.throws(() => recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-a'), LeaderRevivalPromiseError); + assert.deepEqual(records, before); + const unrelated = recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-b'); + assert.equal(unrelated[0], records[0]); assert.equal(unrelated[1], records[1]); + assert.equal(unrelated[1].fulfilled, undefined, 'An unrelated return does not close a No answer'); + for (const leader of ['', ' ', ' leader-a', 'leader-a ']) + assert.throws(() => recordPromisedLeaderRevival(records, 'respondent', 2, leader), LeaderRevivalPromiseError); +}); + +void test('earlier completed returns never satisfy a newly answered named-leader commitment', () => { + const first = recordPromisedLeaderRevival([promise('earlier-answer')], 'respondent', 2, 'leader-a'); + const later = [...first, promise('new-answer')]; + assert.equal(first[0].fulfilled, true); assert.equal(later[1].fulfilled, undefined); + assert.deepEqual(leaderRevivalRequirements(later, 'respondent', 2), { required: ['leader-a'], forbidden: [] }); + assert.equal(leaderRevivalPromisesSatisfied(later, 'respondent', 2, []), false); + const completed = recordPromisedLeaderRevival(later, 'respondent', 2, 'leader-a'); + assert.equal(completed[0], first[0]); assert.equal(completed[1].fulfilled, true); + assert.equal(later[1].fulfilled, undefined, 'Only a new actual return event changes the later ledger entry'); +}); + +void test('native KH is a distinct named identity and the ledger does not turn a physical leader return into a KH return', () => { + const records = [promise('yes-kh', 'kwisatz'), promise('no-leader-a', 'leader-a', { answer: false })]; + assert.deepEqual(leaderRevivalRequirements(records, 'respondent', 2), { required: ['kwisatz'], forbidden: ['leader-a'] }); + assert.equal(leaderRevivalPromisesSatisfied(records, 'respondent', 2, ['leader-b']), false); + const other = recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-b'); + assert.equal(other[0], records[0]); + const returned = recordPromisedLeaderRevival(records, 'respondent', 2, 'kwisatz'); + assert.equal(returned[0].fulfilled, true); assert.equal(returned[1], records[1]); + assert.equal(leaderRevivalPromisesSatisfied(returned, 'respondent', 2, []), true); +}); + +void test('other actors, old turns and terminal records preserve their exact identities and do not constrain the current return', () => { + const live = promise('live'); + const records = [live, promise('other-player', 'leader-a', { player: 'other-player', answer: false }), + promise('old-turn', 'leader-a', { turn: 1, answer: false }), + promise('fulfilled', 'leader-a', { fulfilled: true }), promise('released', 'leader-a', { answer: false, released: true }), + promise('other-disc', 'leader-b')]; + assert.deepEqual(liveLeaderRevivalPromises(records, 'respondent', 2), [live, records[5]]); + const returned = recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-a'); + assert.equal(returned[0].fulfilled, true); + for (let index = 1; index < records.length; index++) assert.equal(returned[index], records[index]); + assert.deepEqual(leaderRevivalRequirements(records, 'absent', 2), { required: [], forbidden: [] }); + const absent = recordPromisedLeaderRevival(records, 'absent', 2, 'leader-a'); + records.forEach((record, index) => assert.equal(absent[index], record)); +}); + +void test('record validation binds unique events, actual participants, exact named identity and compatible terminal flags', () => { + const valid = [promise('live'), promise('kh', 'kwisatz'), promise('fulfilled-yes', 'leader-b', { fulfilled: true }), + promise('released-yes', 'leader-a', { released: true }), + promise('fulfilled-no', 'leader-b', { answer: false, fulfilled: true }), + promise('released-no', 'leader-a', { answer: false, released: true })]; + const before = structuredClone(valid); + assert.doesNotThrow(() => validateLeaderRevivalPromises(valid, seats)); assert.deepEqual(valid, before); + assert.doesNotThrow(() => validateLeaderRevivalPromises(JSON.parse(JSON.stringify(valid)) as LeaderRevivalPromise[], seats)); + assert.doesNotThrow(() => validateLeaderRevivalPromises([], seats)); + const base = promise('event'); + const invalid: unknown[] = [null, {}, [null], [base, { ...base }], + ...[{ event: '' }, { event: ' ' }, { player: 'outsider' }, { asker: 'outsider' }, { asker: 'respondent' }, + { leader: '' }, { leader: ' leader-a' }, { leader: null }, { answer: 'yes' }, + { fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true }, { turn: 0 }] + .map(extra => [{ ...base, ...extra }])]; + for (const records of invalid) + assert.throws(() => validateLeaderRevivalPromises(records as LeaderRevivalPromise[], seats), LeaderRevivalPromiseError); + assert.deepEqual(valid, before); +}); diff --git a/tests/truth-leader-revival-controls.test.tsx b/tests/truth-leader-revival-controls.test.tsx new file mode 100644 index 00000000..ed6aeb6a --- /dev/null +++ b/tests/truth-leader-revival-controls.test.tsx @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from '../components/leader-revival-claim-fields'; +import { LeaderRevivalPromises, LeaderRevivalPromiseGuideButton } from '../components/leader-revival-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import type { LeaderRevivalClaim } from '../game/leader-revival-promises'; +import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction'; +import { createTechTokens } from '../game/tech-tokens'; + +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +type Props = Parameters[0]; +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function choose(props: Parameters[0], value: string) { + const select = elements(LeaderRevivalClaimFields(props)).find(element => element.props.id === 'truth-leader-revival-leader'); + assert.ok(select); + (select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); +} +function guide(props: Props) { const button = LeaderRevivalPromiseGuideButton(props); assert.ok(button); return button; } +const press = (button: ReturnType) => button.props.onClick(); +const markup = (game: GameView) => renderToStaticMarkup(createElement(LeaderRevivalPromises, { game, busy: false, send() {} })); + +/** Controlled personalized projections test UI guards and exact callbacks. + * These are not native acquisition, casualty, eligibility or payment evidence. */ +function projected(phase = 4, type = 'reviveLeader', advanced = false): GameView { + const source = createGame('TRUTHLEADERREVIVALCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced); + joinGame(source, newPlayer('a', 'Asker', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = []; + game.allLeaders = [{ id: 'named-leader', name: 'Named Commander', faction: 'emperor', strength: 3 }]; + game.leaderRevivalPromises = [{ event: 'controlled-leader-revival-event', player: 'p', asker: 'a', + turn: 2, leader: type === 'reviveKwisatz' ? 'kwisatz' : 'named-leader', answer: type !== 'ready' }]; + game.revivalOrder = advanced ? { version: 1, turn: 2, original: ['p', 'a'], remaining: ['p', 'a'], completed: [], started: false } : null; + game.active = advanced ? 'p' : null; + game.auction = phase === 3 ? { bid: 4, bidder: type === 'decision' ? 'p' : 'a', active: type === 'decision' ? 'a' : 'p', + remaining: 2, inspectionBlocked: null, card: null } : null; + game.decision = type === 'decision' ? { kind: 'auctionPayment', player: 'p' } : null; + if (type === 'acceptLeaderRevival') game.revivalRequests.p = { leader: 'named-leader', price: 3 }; + game.leaderRevivalPromiseGuide = { phase, action: { type, leaderRevivalPromiseEvent: 'controlled-leader-revival-event', + ...(type === 'reviveLeader' ? { leader: 'named-leader' } : {}), + ...(type === 'revive' ? { amount: 1, elite: 0, freeElite: 0 } : {}), + ...(type === 'acceptLeaderRevival' ? { leaderRevivalPrice: game.revivalRequests.p.price } : {}), + ...(type === 'decision' ? { karama: true, card: 'projected-original-karama' } : {}) } }; + return game; +} +function nativeProjected(method: 'silent' | 'onceAround') { + const game = projected(3, 'richeseBid'); + const native = createRicheseAuction({ event: 'controlled-native-auction-event', cardId: 'controlled-card', owner: 'a', + method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] }); + game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null, + ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null }; + game.leaderRevivalPromiseGuide = { phase: 3, action: { type: 'richeseBid', event: native.event, + amount: method === 'silent' ? 0 : null, allyPayment: 0, leaderRevivalPromiseEvent: 'controlled-leader-revival-event' } }; + return game; +} + +void test('named-leader fields preserve the fixed turn and emit only parser-valid current name choices', () => { + const value: LeaderRevivalClaim = { turn: 2, leader: 'named-leader' }, changes: LeaderRevivalClaim[] = []; + const leaders = [{ id: 'named-leader', name: 'Named Commander' }, { id: 'kwisatz', name: 'Kwisatz Haderach' }]; + const props = { value, leaders, onChange(next: LeaderRevivalClaim) { changes.push(next); } }; + const html = renderToStaticMarkup(createElement(LeaderRevivalClaimFields, props)); + assert.match(html, /Turn 2/); assert.match(html, /Named Commander/); assert.match(html, /Kwisatz Haderach/); + assert.match(html, /A request alone is not a return/); assert.match(html, /Native revival cycles, costs and turn order still apply/); + assert.equal(elements(LeaderRevivalClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1); + choose(props, 'kwisatz'); assert.deepEqual(changes, [{ turn: 2, leader: 'kwisatz' }]); + for (const value of ['', 'missing', ' named-leader ']) choose(props, value); + choose({ ...props, disabled: true }, 'kwisatz'); choose({ ...props, leaders: [] }, 'kwisatz'); + choose({ ...props, value: { ...props.value, turn: 0 } }, 'kwisatz'); assert.equal(changes.length, 1); + for (const value of [null, {}, { turn: 0, leader: 'named-leader' }, { turn: 2, leader: '' }]) assert.ok(leaderRevivalClaimInputError(value)); + assert.ok(leaderRevivalClaimInputError(value, [])); + assert.match(renderToStaticMarkup(createElement(LeaderRevivalClaimFields, { ...props, leaders: [] })), /role="alert"/); +}); + +void test('own leader promises persist with public names, actual-return meaning, statuses and earlier-phase waiting', () => { + const game = projected(); game.leaderRevivalPromiseGuide = null; + game.leaderRevivalPromises.push( + { event: 'no-event', player: 'p', asker: 'a', turn: 2, leader: 'kwisatz', answer: false }, + { event: 'completed-event', player: 'p', asker: 'a', turn: 1, leader: 'named-leader', answer: true, fulfilled: true }, + { event: 'released-event', player: 'p', asker: 'a', turn: 2, leader: 'missing', answer: true, released: true }, + { event: 'foreign-event', player: 'a', asker: 'p', turn: 2, leader: 'foreign-hidden-leader-id', answer: true }, + ); + const before = structuredClone(game); + for (let render = 0; render < 2; render++) { + const html = markup(game); + assert.match(html, /Your leader Revival promises/); assert.match(html, /Named Commander/); assert.match(html, /Kwisatz Haderach/); + assert.match(html, /Yes requires the named leader’s future actual ordinary native return/); + assert.match(html, /No forbids that named ordinary return until your Revival opportunity ends this turn/); + assert.match(html, /A request alone is not a return/); assert.match(html, /Prior returns, Ghola, foreign leaders and special card returns do not count/); + assert.match(html, /Original revival cycles, costs, limits and turn order still apply/); + assert.match(html, /accept an existing, current Tleilaxu quote/); assert.match(html, /does not arrange a new agreement or promise future consent/); + assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/); + assert.match(html, /href="\/rules#truthtrance-leader-revival"/); + assert.doesNotMatch(html, /foreign-hidden-leader-id|controlled-leader-revival-event|released-event/); + } + assert.deepEqual(game, before); + for (const phase of [0, 1, 2, 3]) { + game.phase = phase; + assert.match(markup(game), /Your leader Revival opportunity is still ahead/); + assert.equal(LeaderRevivalPromiseGuideButton({ game, busy: false, send() {} }), null); + } + game.phase = 4; game.revivalOrder = { version: 1, turn: 2, original: ['a', 'p'], remaining: ['a', 'p'], completed: [], started: false }; + assert.match(markup(game), /this promise remains visible while the current actor finishes/); + assert.equal(LeaderRevivalPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null); +}); + +void test('actual Basic and ordered Advanced guide callbacks preserve named leader, KH, priced negotiation, joint-force and Ready packets', () => { + for (const advanced of [false, true]) for (const type of ['reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'revive', 'ready']) { + const game = projected(4, type, advanced), sent: Action[] = []; + const props = { game, busy: false, send(action: Action) { sent.push(action); } }; + const button = guide(props); assert.equal(button.props.disabled, false); press(button); + assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]); assert.equal(sent[0], game.leaderRevivalPromiseGuide!.action); + assert.match(markup(game), type === 'reviveLeader' ? /Revive Named Commander while honoring your answers/ + : type === 'reviveKwisatz' ? /Revive Kwisatz Haderach while honoring your answers/ + : type === 'acceptLeaderRevival' ? /Accept the current quoted leader revival while honoring your answers/ + : type === 'revive' ? /Revive 1 force to prepare your leader Revival/ : /Finish Revival while honoring your named-leader answers/); + press(guide({ ...props, busy: true })); assert.equal(sent.length, 1); + if (type === 'acceptLeaderRevival') { + assert.deepEqual(sent[0], { type: 'acceptLeaderRevival', leaderRevivalPromiseEvent: 'controlled-leader-revival-event', leaderRevivalPrice: 3 }); + game.revivalRequests.p.price = 4; press(button); assert.equal(sent.length, 1); + game.revivalRequests.p.price = 3; game.revivalRequests.p.declined = true; press(button); assert.equal(sent.length, 1); + delete game.revivalRequests.p; press(button); assert.equal(sent.length, 1); + } + } +}); + +void test('actual preparation callbacks preserve Charity, owned ordinary payment, and current Richese zero or pass packets', () => { + for (const [phase, type] of [[2, 'charity'], [2, 'ready'], [3, 'passBid'], [3, 'decision']] as const) { + const game = projected(phase, type), sent: Action[] = []; + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]); + if (type === 'decision') { + assert.equal(game.auction!.active, 'a'); assert.equal(sent[0].card, 'projected-original-karama'); assert.equal(sent[0].karama, true); + assert.match(markup(game), /Use the quoted Karama auction payment for your later leader Revival/); + assert.doesNotMatch(markup(game), /projected-original-karama/); + game.decision!.player = 'a'; press(button); assert.equal(sent.length, 1); + } else if (phase === 2) { game.ready.push('p'); press(button); assert.equal(sent.length, 1); } + else { game.auction!.active = 'a'; press(button); assert.equal(sent.length, 1); } + } + for (const method of ['silent', 'onceAround'] as const) { + const game = nativeProjected(method), sent: Action[] = []; + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]); + assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/); + game.richeseAuction!.event = 'later-event'; press(button); assert.equal(sent.length, 1); + game.richeseAuction!.event = 'controlled-native-auction-event'; + if (method === 'silent') game.richeseAuction!.submitted.push('p'); else game.richeseAuction!.active = 'a'; + press(button); assert.equal(sent.length, 1); + } +}); + +void test('leader guide callbacks reject stale actor, event, guide, order and interrupted windows without changing native actions', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 5; }, + game => { game.status = 'setup'; }, game => { game.ready = ['p']; }, + game => { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; }, + game => { game.revivalOrder!.turn = 1; }, game => { game.active = null; }, + game => { game.revivalOrder = null; }, game => { game.revivalOrder = { ...game.revivalOrder!, remaining: ['p', 'a'] }; }, + game => { game.leaderRevivalPromiseGuide = { phase: 4, action: { ...game.leaderRevivalPromiseGuide!.action } }; }, + game => { game.leaderRevivalPromiseGuide = null; }, game => { game.leaderRevivalPromiseGuide!.phase = 3; }, + game => { game.leaderRevivalPromiseGuide!.action.leader = 'kwisatz'; }, + game => { game.leaderRevivalPromises[0].event = 'expired'; }, game => { game.leaderRevivalPromises[0].player = 'a'; }, + game => { game.leaderRevivalPromises[0].turn = 1; }, game => { game.leaderRevivalPromises[0].fulfilled = true; }, + game => { game.leaderRevivalPromises[0].released = true; }, game => { game.leaderRevivalPromises = []; }, + game => { game.phaseOpening = { passed: [] }; }, + game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, + game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, + game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; }, + game => { game.automaticContinuationPending = true; }, + game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; }, + game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; }, + game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; }, + game => { game.techTokens = createTechTokens(); }, + ]; + for (const change of changes) { + const game = projected(4, 'reviveLeader', true), sent: Action[] = []; + const props = { game, busy: false, send(action: Action) { sent.push(action); } }; + const button = guide(props); change(game); press(button); assert.deepEqual(sent, []); + } + const props = { game: projected(), busy: false, send() { assert.fail('A now-busy leader guide cannot dispatch'); } }; + const button = guide(props); props.busy = true; press(button); + for (const [phase, type] of [[0, 'ready'], [1, 'ready'], [2, 'reviveLeader'], [3, 'reviveLeader'], [4, 'charity']] as const) { + const game = projected(phase, type), sent: Action[] = []; + press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []); + } +}); + +void test('named-leader guidance uses only public names and the projected guide without private eligibility inspection', () => { + for (const type of ['reviveLeader', 'reviveKwisatz', 'decision']) { + const game = projected(type === 'decision' ? 3 : 4, type), sent: Action[] = []; + for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'reserves', 'tanks', 'elites']) + Object.defineProperty(seat, key, { get() { throw Error(`No private or unneeded ${key} inspection`); }, configurable: true }); + if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No unrevealed lot inspection'); } }); + for (const key of ['spiceDeck', 'deck', 'discard', 'revival']) Object.defineProperty(game, key, { + get() { throw Error(`No private eligibility or ${key} inspection`); }, configurable: true, + }); + assert.match(markup(game), /Your leader Revival promises/); + press(guide({ game, busy: false, send(action) { sent.push(action); } })); + assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]); + } +}); diff --git a/tests/truth-leader-revival-funding.test.ts b/tests/truth-leader-revival-funding.test.ts new file mode 100644 index 00000000..b8c963f9 --- /dev/null +++ b/tests/truth-leader-revival-funding.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { + createTruthLeaderRevivalFixture as fixture, declareTruthLeaderRevival as declare, + askTruthLeaderRevival as ask, answerTruthLeaderRevival as answer, + actTruthLeaderRevival as act, settleTruthLeaderRevival as settle, + finishTruthLeaderRevival as finish, truthLeaderRevivalPlayer as player, + truthLeaderRevivalInventory as inventory, +} from './fixture-truth-leader-revival'; + +void test('an earlier funded Bribe Yes retains its purse before a named leader answer', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'bribe', target: f.target, turn: game.turn, recipient: f.opponent, minimum: 4, + } }); + game = answer(game, f.target, 'yes'); + game = ask(game, f.asker, f.target, f.leader); + assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['no']); + const before = structuredClone(game); + assert.throws(() => answer(game, f.target, 'yes')); assert.deepEqual(game, before); + game = answer(game, f.target, 'no'); + const payment = viewGame(game, f.target).bribePromiseGuide; assert.ok(payment); + game = act(game, f.target, payment.action); + assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.opponent).bribes, 4); + game = finish(game); + assert.equal(game.bribePromises![0].fulfilled, true); + assert.equal(game.leaderRevivalPromises![0].fulfilled, true); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('earlier named Yes limits a later bribe and preserves the actual self-income preparation', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = answer(ask(game, f.asker, f.target, f.leader), f.target, 'yes'); + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'bribe', target: f.target, turn: game.turn, recipient: f.opponent, minimum: 4, + } }); + assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']); + game = answer(game, f.target, 'no'); + const before = structuredClone(game); + assert.throws(() => act(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 })); + assert.deepEqual(game, before); + game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 3 }); + assert.equal(player(game, f.target).spice, 1); + let guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'revive'); + game = settle(act(game, f.target, guide.action)); + assert.equal(player(game, f.target).spice, 2); + assert.ok(!game.leaderRevivalPromises![0].fulfilled); + guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'reviveLeader'); + game = settle(act(game, f.target, guide.action)); + assert.equal(player(game, f.target).spice, 0); + assert.equal(game.leaderRevivalPromises![0].fulfilled, true); + assert.equal(player(game, f.opponent).bribes, 3); + game = finish(game); + assert.ok(!game.bribePromises![0].fulfilled, 'The turn-wide bribe No outlasts Revival'); + assert.equal(game.bribePromises![0].progress, 3); + assert.deepEqual(inventory(game), f.originalCards); +}); diff --git a/tests/truth-leader-revival-runtime.test.ts b/tests/truth-leader-revival-runtime.test.ts new file mode 100644 index 00000000..f8e114d7 --- /dev/null +++ b/tests/truth-leader-revival-runtime.test.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + actTruthLeaderRevival as act, advanceTruthLeaderRevival as advance, answerTruthLeaderRevival as answer, + askTruthLeaderRevival as ask, bindTruthLeaderRevival as bind, createTruthLeaderRevivalFixture as fixture, + declareTruthLeaderRevival as declare, finishTruthLeaderRevival as finish, settleTruthLeaderRevival as settle, + truthLeaderRevivalClean as clean, truthLeaderRevivalInventory as inventory, truthLeaderRevivalPlayer as player, +} from './fixture-truth-leader-revival'; + +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +const promise = (game: Game, target: string) => game.leaderRevivalPromises!.find(promise => promise.player === target)!; + +for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} named Tleilaxu Revival Yes follows real projectile death and original early return`, () => { + const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.deepEqual(f.game.expansions, ['ix']); assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []); + assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCount, f.sourceActions.length); + assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal); + assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 1); + assert.equal(player(f.afterBattle, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true); + assert.equal(player(f.game, f.target).leaders.filter(leader => !leader.dead).length, 4); + assert.equal(player(f.game, f.target).revivalCycle, 0); assert.equal(f.quote.early, true); assert.equal(f.quote.cost, 2); + assert.equal(player(f.game, f.target).tanks, 1); assert.equal(player(f.game, f.target).spice, 4); + const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before); + assert.ok(!promise(bound.game, f.target).fulfilled); rejected(bound.game, f.target, { type: 'ready' }); + const guide = viewGame(bound.game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 4); + assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader); + let game = act(bound.game, f.target, guide.action); + if (game.pendingRevival) { + assert.ok(!promise(game, f.target).fulfilled); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true); + } + game = settle(game); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, false); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.deaths, 1); + assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).leaderRevived, true); + assert.equal(player(game, f.target).tanks, 1); assert.equal(player(game, f.target).revived, 0); + assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); + assert.deepEqual(inventory(finish(game)), f.originalCards); +}); + +void test('native named No rejects the exact ordinary leader return while an independent free force return stays legal', () => { + const f = fixture(); let game = bind(f, 'no').game; + rejected(game, f.target, f.revivalAction); + game = settle(act(game, f.target, { type: 'revive', amount: 1, elite: 0 })); + assert.ok(!promise(game, f.target).fulfilled); assert.equal(player(game, f.target).revived, 1); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true); + assert.equal(player(game, f.target).spice, 5, 'Original self free-revival bank1 stays separate'); + game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native Ghola can return the named leader without fulfilling or violating an ordinary-return No', () => { + const f = fixture({ ghola: true }); let game = bind(f, 'no').game; assert.ok(f.ghola); + game = settle(act(game, f.target, { type: 'card', card: f.ghola, leader: f.leader })); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, false); + assert.equal(player(game, f.target).leaderRevived, false); assert.ok(!promise(game, f.target).fulfilled); + assert.equal(player(game, f.target).spice, 5, 'Printed Ghola bank1 is distinct from an ordinary leader invoice'); + assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1); + game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native Ghola cannot erase the only named ordinary-return Yes witness', () => { + const f = fixture({ ghola: true }), game = bind(f).game; assert.ok(f.ghola); + rejected(game, f.target, { type: 'card', card: f.ghola, leader: f.leader }); + assert.ok(player(game, f.target).hand.some(card => card.id === f.ghola)); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true); +}); + +void test('native prior named ordinary return cannot satisfy a newly answered question about future returns', () => { + const f = fixture(); let game = settle(act(f.game, f.target, f.revivalAction)); + assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).leaderRevived, true); + game = declare(f, game).game; game = ask(game, f.asker, f.target, f.leader); + assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no'); + assert.ok(!promise(game, f.target).fulfilled); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native waiting Advanced named return keeps original Revival order and exposes its guide only when current', () => { + const f = fixture({ advanced: true, window: 'waiting' }); assert.notEqual(f.game.revivalOrder!.remaining[0], f.target); + let game = bind(f).game; assert.equal(viewGame(game, f.target).leaderRevivalPromiseGuide, null); + game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target); + const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); + game = settle(act(game, f.target, guide.action)); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truth copies require the same physical leader once at its native price', () => { + const f = fixture(), declared = declare(f, f.game, { copies: 2 }); + let game = ask(declared.game, f.asker, f.target, f.leader); game = answer(game, f.target, 'yes'); + game = ask(game, f.asker, f.target, f.leader); assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['yes']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes'); + const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); game = settle(act(game, f.target, guide.action)); + assert.ok(game.leaderRevivalPromises!.every(promise => promise.fulfilled)); assert.equal(player(game, f.target).spice, 2); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.deaths, 1); + for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truth copies cannot contradict a matching named ordinary-return No', () => { + const f = fixture(), declared = declare(f, f.game, { copies: 2 }); + let game = ask(declared.game, f.asker, f.target, f.leader); game = answer(game, f.target, 'no'); + game = ask(game, f.asker, f.target, f.leader); assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no'); + rejected(game, f.target, f.revivalAction); assert.equal(player(game, f.target).spice, 4); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native joint force and named leader Yes preserve actual separate prices allowances and bank income', () => { + const f = fixture(), declared = declare(f, f.game, { copies: 2 }); + let game = ask(declared.game, f.asker, f.target, f.leader); game = answer(game, f.target, 'yes'); + game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1 } }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + for (let count = 0; count < 4 && (!promise(game, f.target).fulfilled || !game.revivalPromises![0].fulfilled); count++) { + const own = viewGame(game, f.target), guide = own.leaderRevivalPromiseGuide ?? own.revivalPromiseGuide; assert.ok(guide); + game = settle(act(game, f.target, guide.action)); + } + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.revivalPromises![0].progress, 1); + assert.equal(game.revivalPromises![0].fulfilled, true); assert.equal(player(game, f.target).leaderRevived, true); + assert.equal(player(game, f.target).tanks, 0); assert.equal(player(game, f.target).revived, 1); + assert.equal(player(game, f.target).freeForcesRevived, 1); assert.equal(player(game, f.target).spice, 3); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native named Yes may prepare its price through an actual owned free-force return and Tleilaxu bank income', () => { + const f = fixture(); let game = act(f.game, f.target, { type: 'bribe', target: f.asker, amount: 3 }); + assert.equal(player(game, f.target).spice, 1); game = bind(f, 'yes', f.leader, game).game; + const preparation = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(preparation); + assert.equal(preparation.action.type, 'revive'); assert.equal(preparation.action.amount, 1); + game = settle(act(game, f.target, preparation.action)); + assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).freeForcesRevived, 1); + assert.equal(player(game, f.target).tanks, 0); assert.ok(!promise(game, f.target).fulfilled); + assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true); + const returned = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(returned); assert.equal(returned.action.type, 'reviveLeader'); + game = settle(act(game, f.target, returned.action)); + assert.equal(player(game, f.target).spice, 0); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native force No prevents using its forbidden free return to fund a named leader Yes', () => { + const f = fixture(); let game = act(f.game, f.target, { type: 'bribe', target: f.asker, amount: 3 }); + game = declare(f, game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1 } }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); + game = ask(game, f.asker, f.target, f.leader); + assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no'); + assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.target).tanks, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); + +for (const permitted of [false, true]) void test(`native actually priced personal early leader quote ${permitted ? 'completes the named Yes' : 'stays blocked by a named No'}`, () => { + const f = fixture({ advanced: true, window: 'waiting', bothDead: true }); + assert.equal(player(f.afterBattle, f.opponent).leaders.find(leader => leader.id === f.opponentLeader)!.dead, true); + assert.equal(player(f.game, f.opponent).leaders.filter(leader => !leader.dead).length, 4); + assert.equal(f.game.revivalOrder!.remaining[0], f.opponent); + let game = act(f.game, f.opponent, { type: 'requestLeaderRevival', leader: f.opponentLeader }); + game = act(game, f.target, { type: 'quoteLeaderRevival', target: f.opponent, amount: 3 }); + const before = player(game, f.opponent).spice, payee = player(game, f.target).spice; + game = declare(f, game).game; game = ask(game, f.asker, f.opponent, f.opponentLeader); + game = answer(game, f.opponent, permitted ? 'yes' : 'no'); + if (permitted) { + const guide = viewGame(game, f.opponent).leaderRevivalPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'acceptLeaderRevival'); assert.equal(guide.action.leaderRevivalPrice, 3); + game = settle(act(game, f.opponent, guide.action)); + assert.equal(player(game, f.opponent).leaders.find(leader => leader.id === f.opponentLeader)!.dead, false); + assert.equal(player(game, f.opponent).spice, before - 3); assert.equal(player(game, f.target).spice, payee + 3); + assert.equal(player(game, f.opponent).leaderRevived, true); assert.equal(promise(game, f.opponent).fulfilled, true); + } else { + rejected(game, f.opponent, { type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }); + assert.equal(player(game, f.opponent).spice, before); + assert.equal(player(game, f.opponent).leaders.find(leader => leader.id === f.opponentLeader)!.dead, true); + } + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native named question ownership exact identity and guided event reject stale or foreign packets', () => { + const f = fixture({ advanced: true }), declared = declare(f); + rejected(declared.game, f.opponent, f.questionAction); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } }); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, leader: 'atreides-0' } }); + let game = ask(declared.game, f.asker, f.target, f.leader); + assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['yes', 'no']); + assert.equal(viewGame(game, f.asker).truthLeaderRevivalAnswers, null); + rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes'); + const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); assert.ok(guide.action.leaderRevivalPromiseEvent); + rejected(game, f.target, { ...guide.action, leaderRevivalPromiseEvent: 'stale-owned-leader-event' }); + rejected(game, f.opponent, guide.action); + game = settle(act(game, f.target, guide.action)); rejected(game, f.target, guide.action); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('all four minimal policies offer a legal real named leader return at the current native opportunity', () => { + const f = fixture(), game = bind(f).game; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'reviveLeader' && action.leader === f.leader); assert.ok(action, difficulty); + const returned = settle(act(structuredClone(game), f.target, action)); + assert.equal(promise(returned, f.target).fulfilled, true); assert.equal(player(returned, f.target).spice, 2); + assert.deepEqual(inventory(returned), f.originalCards); + } +});