From 5627512d160242ce9b817e6a547076f7e006f43f Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:22:46 +1100 Subject: [PATCH] Bind Truthtrance alliance promises to actual Nexus closure --- components/alliance-claim-fields.tsx | 42 ++++ components/alliance-promises.tsx | 81 +++++++ components/game-table.tsx | 2 + components/truthtrance.tsx | 29 ++- docs/CURRENT_STATUS.md | 32 +++ docs/IMPLEMENTATION_STATUS.md | 34 +++ docs/RULE_DECISIONS.md | 19 ++ docs/TRUTHTRANCE_ALLIANCE_PROMISES.md | 117 ++++++++++ ...RUTHTRANCE_NONBATTLE_READINESS_20260907.md | 5 + game/alliance-promises.ts | 134 +++++++++++ game/bots.ts | 5 + game/engine.ts | 208 +++++++++++++++--- game/reference.ts | 29 ++- game/truthtrance.ts | 24 ++ tests/alliance-promises.test.ts | 206 +++++++++++++++++ tests/fixture-truth-alliance.ts | 123 +++++++++++ tests/truth-alliance-controls.test.tsx | 205 +++++++++++++++++ tests/truth-alliance-interactions.test.ts | 54 +++++ tests/truth-alliance-runtime.test.ts | 166 ++++++++++++++ 19 files changed, 1485 insertions(+), 30 deletions(-) create mode 100644 components/alliance-claim-fields.tsx create mode 100644 components/alliance-promises.tsx create mode 100644 docs/TRUTHTRANCE_ALLIANCE_PROMISES.md create mode 100644 game/alliance-promises.ts create mode 100644 tests/alliance-promises.test.ts create mode 100644 tests/fixture-truth-alliance.ts create mode 100644 tests/truth-alliance-controls.test.tsx create mode 100644 tests/truth-alliance-interactions.test.ts create mode 100644 tests/truth-alliance-runtime.test.ts diff --git a/components/alliance-claim-fields.tsx b/components/alliance-claim-fields.tsx new file mode 100644 index 00000000..e25cc474 --- /dev/null +++ b/components/alliance-claim-fields.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { parseAllianceClaim, type AllianceClaim } from '@/game/alliance-promises'; + +type Partner = { id: string; name: string }; +export function allianceClaimInputError(value: unknown, partners?: readonly Partner[]): string | null { + try { + const claim = parseAllianceClaim(value); + return partners && !partners.some(partner => partner.id === claim.partner) + ? 'Choose one of the current alliance partners.' : null; + } catch (error) { return error instanceof Error ? error.message : 'Choose the current turn and another player.'; } +} + +/** The parent supplies this Nexus turn and its current named-other choices. */ +export function AllianceClaimFields(props: { + value: AllianceClaim; + onChange: (claim: AllianceClaim) => void; + partners: readonly Partner[]; + disabled?: boolean; +}) { + const error = allianceClaimInputError(props.value, props.partners); + return
+ Final alliance at this Nexus close +

Turn {props.value.turn} · Current Nexus

+ + + {error &&

{error}

} +

+ This asks about actual membership when this Nexus closes. Offers alone are not alliances. + A Yes needs an existing alliance or a real reciprocal invitation; it cannot promise the other player’s consent. + Otherwise No or unknown may apply, and the holder can ask again or save the card. +

+
; +} diff --git a/components/alliance-promises.tsx b/components/alliance-promises.tsx new file mode 100644 index 00000000..17126234 --- /dev/null +++ b/components/alliance-promises.tsx @@ -0,0 +1,81 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises'; +import { quoteNexusAlliance } from '@/game/nexus-alliance'; +import { Button } from './ui/button'; + +type AlliancePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; +const publicContext = (game: GameView) => ({ + players: game.players.map(player => ({ id: player.id, ally: player.ally })), offers: game.allianceOffers, +}); + +/** A guide may end a pair, accept real consent or confirm current membership. + * It never sends a proposal on another player's behalf. */ +export function AlliancePromiseGuideButton(props: AlliancePromiseProps) { + const game = props.game, guide = game.alliancePromiseGuide, nexus = game.alliancePromiseNexus; + if (!guide || !nexus) return null; + const actor = game.me, turn = nexus.turn, action = guide.action; + const event = action.alliancePromiseEvent, signature = JSON.stringify(action); + const contextSignature = JSON.stringify(publicContext(game)); + const canAct = () => { + const current = props.game, me = current.players.find(player => player.id === current.me); + const live = current.alliancePromises.filter(promise => promise.player === actor && promise.turn === turn && + !promise.fulfilled && !promise.released); + let ownedAction = action.type === 'ready' && !!me && live.every(promise => (me.ally === promise.partner) === promise.answer); + if (action.type === 'alliance') { + if (action.target === null) ownedAction = true; + else if (typeof action.target === 'string' && action.target !== actor && current.players.some(player => player.id === action.target)) { + try { + const quote = quoteNexusAlliance({ ...publicContext(current), actor, target: action.target }); + ownedAction = !!quote.formed && live.every(promise => (quote.allies[actor] === promise.partner) === promise.answer); + } catch { ownedAction = false; } + } + } + return !props.busy && current === game && alliancePromiseModeSupported(current) && current.status === 'playing' && + current.phase === 1 && current.nexus && !current.spiceWindow && !current.ready.includes(actor) && + current.me === actor && current.turn === turn && current.alliancePromiseNexus?.turn === turn && + current.alliancePromiseGuide === guide && current.alliancePromiseGuide.action === action && JSON.stringify(action) === signature && + JSON.stringify(publicContext(current)) === contextSignature && typeof event === 'string' && + live.some(promise => promise.event === event) && ownedAction && !!me && !me.autopilot && + !current.truthtrance && !current.response && !current.decision && !current.phaseOpening && + !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; + }; + const me = game.players.find(player => player.id === actor); + const partner = game.players.find(player => player.id === action.target)?.name ?? 'the named partner'; + return ; +} + +export function AlliancePromises(props: AlliancePromiseProps) { + const own = props.game.alliancePromises.filter(promise => promise.player === props.game.me); + if (!own.length) return null; + const name = (id: string) => props.game.players.find(player => player.id === id)?.name ?? 'the named partner'; + return
+

Your alliance promises

+ +

+ The answer concerns your final actual alliance membership at this Nexus close. + Yes remains live after forming an alliance and settles only at the close. + No requires ending without the named partner; a temporary alliance can still be ended before the close. + Offers alone are not alliances. +

+

+ An owned Yes route needs an existing alliance or an actual reciprocal invitation. + Neither answer promises another player’s consent or prevents their original actions. + If no owned route exists, No or unknown may apply; the holder can ask again or save the card. + Honor any earlier promises first; their own controls handle required payments. + {' '}Alliance promise rules. +

+ +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index a6d623ec..7e318360 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -24,6 +24,7 @@ import { liveShipmentPromises, } from '@/game/shipment-promises'; import { ShipmentPromises } from './shipment-promises'; +import { AlliancePromises } from './alliance-promises'; import { BribePromises } from './bribe-promises'; import { RevivalPromises } from './revival-promises'; import { BidPromises } from './bid-promises'; @@ -2034,6 +2035,7 @@ export function GameTable({ )} {g.status === 'playing' && } + {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } {g.guildBetrayalReaction ? ( diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index abf7dca8..925bf8ad 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,5 +1,7 @@ 'use client'; import { MovementClaimFields } from './movement-claim-fields'; +import { AllianceClaimFields, allianceClaimInputError } from './alliance-claim-fields'; +import type { AllianceClaim } from '@/game/alliance-promises'; import { BribeClaimFields, bribeClaimInputError } from './bribe-claim-fields'; import type { BribeClaim } from '@/game/bribe-promises'; import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields'; @@ -59,8 +61,11 @@ 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' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' >('fact'); + const [alliancePartner, setAlliancePartner] = useState(g.me); + const allianceClaim: AllianceClaim = { turn: g.turn, partner: alliancePartner }; + const alliancePartners = g.players.filter(player => player.id !== target); const [bribeSelection, setBribeSelection] = useState>({ recipient: g.me, minimum: 3 }); const bribeClaim: BribeClaim = { turn: g.turn, ...bribeSelection }; const bribeRespondent = g.players.find(player => player.id === target); @@ -108,6 +113,7 @@ export function Truthtrance({ } : kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } : kind === 'movement' ? { kind, target, ...movement } + : kind === 'alliance' ? { kind, target, ...allianceClaim } : kind === 'bribe' ? { kind, target, ...bribeClaim } : kind === 'revival' ? { kind, target, ...revivalClaim } : kind === 'bid' ? { kind, target, ...bidClaim } @@ -218,6 +224,13 @@ export function Truthtrance({ ))} + ) : 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.

+ {(g.truthAllianceAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'bribe' ? ( <>

Yes requires cumulative ordinary bribe payments to this named recipient while possible. No keeps all later payments below the minimum. Earlier payments, gifts and allied aid do not count. You pay now; the recipient spends the receipt at Mentat Pause.

@@ -356,6 +369,7 @@ export function Truthtrance({ Verified current facts + @@ -374,7 +388,15 @@ export function Truthtrance({ )} - {kind === 'bribe' ? ( + {kind === 'alliance' ? ( + <> + setAlliancePartner(claim.partner)} /> +

The final actual partnership in the current ordinary Nexus is binding. Yes uses an already held partnership or an actual incoming invitation the respondent can accept; another player’s future consent is not invented. No may require breaking a temporary alliance before readiness.

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

Choose a supported participant in a quiet open ordinary Nexus. Optional modules and broader alliance questions remain unfinished.

} +

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

+ + ) : kind === 'bribe' ? ( <> setBribeSelection({ recipient: claim.recipient, minimum: claim.minimum })} /> @@ -496,6 +518,7 @@ export function Truthtrance({ 'Ask publicly', { type: 'truthAsk', question }, (kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.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))) || (kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) || @@ -519,7 +542,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
Truthtrance answers · Turn {g.turn}

- Supported battle-plan, bid, revival, bribe, movement and reserve-shipment + Supported battle-plan, Nexus-alliance, bid, 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 7854ee6c..40651e57 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,38 @@ open mode gates. ## Current checkpoint and work +**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. +Yes remains live after acceptance until closure; No permits a temporary pair +that is broken before Ready. Unknown can preserve the original card until a +real offer. Persistent break/accept/Ready guides, minimal legal AI, reopening +readiness and opposing-impossibility release are connected. Earlier payments +retain their own guides and must finish before accepting their recipient. +The interval/owned-consent/release meaning is provisional; wider diplomacy and +optional compositions remain due. No start/module gate opens. + +Root shared integration with disjoint pure/native/control lanes and bounded +actor-order review passes **15 files,150/150**, types/lint/build and **six native +paths**,22 GameTable SSR renders/ten actual alliance callbacks plus one prior- +bribe callback. Original H/A/E Basic47/Advanced48-action sources earn the Worm +Nexus and real invitation; another9/12 actions bind, accept and settle at actual +closure. Other paths break an existing pair for No, save/redeclare the same +physical Truth after actual consent, release after opposing withdrawal, and pay +an earlier promised3 before accepting. All33 original cards remain. Two explicit +controlled prior-debt supplements prove actual-actor reconciliation order; +native journeys install no played hand, purse, clock, alliance or promise. + +Frozen v2 report/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-alliance-runtime/`. +Final link review corrects one rules-reference target; its separate reference +test, types/lint and build are recorded in adjacent +`20261026-truth-alliance-reference-proof-v1/` without repeating unchanged rules. +No saved-room/D1/server/reset/restart or deployment operation is performed. +Broader rules, browser/mobile/deployed and final assurance remain open. +[Source and boundaries](TRUTHTRANCE_ALLIANCE_PROMISES.md). + **Truthtrance / ordinary bribe commitments — 26 October. Prototyped / Partial.** Registered bare native Basic/Advanced tables now support funded current-turn cumulative ordinary payments to one named non-allied recipient. Actual payer diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3da4b515..b15ab108 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,39 @@ # Dune implementation status +## 26 October 2026 — Truthtrance current-Nexus alliance membership + +**Prototyped / Partial.** Current quiet ordinary Nexus questions bind the +respondent's actual named partnership at closure in bare native Basic/Advanced. +Canonical reciprocal consent, original break/accept actions, persistent native +guides, minimal legal AI and reopened readiness are connected. Yes stays live +after formation; No allows a temporary pair ended before Ready. Unknown saves +the physical card; opposing withdrawal/break can release impossible Yes. +Earlier payable bribes use their original guide before alliance acceptance. +Earlier financial impossibility is reconciled with its actual actor first. +Interval/owned-consent/release meaning is provisional; optional compositions, +wider diplomacy and complete-mode acceptance remain open. No start gate opens. + +Root integration and disjoint pure/native/control lanes pass **15 files,150/150**, +types/lint/build and **six native paths**,22 GameTable SSR renders/ten actual +alliance callbacks plus one prior-bribe callback. Original public H/A/E +Basic47/Advanced48-action sources earn the actual Worm Nexus and reciprocal +invitation;9/12 further actions bind, accept and settle at actual closure. +Other original paths cover No requiring a real break, unknown/save/redeclaration +of the same physical card, opposing withdrawal/reoffer and stale rejection, +and earlier Bribe Yes3 paid before acceptance. All33 original cards remain. +Ten pure,13 native,six control and three interaction cases retain joint clauses, +minimal policies and actual Ready. Two interaction cases explicitly control +only an inherited prior-debt record; native journeys install no played state. + +Frozen v2 proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-alliance-runtime/`. +One final rules-reference link correction has separate reference-test, +types/lint/build proof in adjacent `20261026-truth-alliance-reference-proof-v1/`. +No saved-room/D1/server/reset/restart or deployment operation is performed. +Browser/mobile/deployed and comprehensive assurance remain due. +[Contract](TRUTHTRANCE_ALLIANCE_PROMISES.md). + ## 26 October 2026 — Truthtrance ordinary bribe-payment promises **Prototyped / Partial.** Funded ordinary bribe questions now bind cumulative diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index eacacba2..fb0255ba 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,24 @@ # Rule decision index +## Truthtrance current-Nexus alliance membership — 26 October 2026 + +Apply general current-turn Truthtrance authority (FAQp8; supplied Advanced +physical39) to actual named partnership when the current ordinary Nexus closes. +Supplied physical15 and the settled ordinary Nexus quote retain reciprocal +consent, at most one partnership, own departure and another chance to form a +pair. A proposal alone is not membership. No allows a temporary pair if it is +broken before the close; Yes stays live after formation until actual closure. + +Under standing14October choose-recommended priority, quote only currently owned +actions using existing membership or a real incoming invitation. Never invent +foreign acceptance or lock another player’s consent. No/unknown may apply when +consent is unavailable; earlier own clauses can fix an answer. Definite binding +reopens own readiness; actual final matching settles before the native draw/ride +continuation. Opposing withdrawal/break may release Yes; own evasion rejects. +This interval/witness/release model is a provisional software composition, not +a dedicated publisher alliance-Truthtrance ruling. Bare native scope first; +no optional/start/publication gate opens. [Contract](TRUTHTRANCE_ALLIANCE_PROMISES.md). + ## Truthtrance ordinary bribe-payment commitments — 26 October 2026 Apply general current-turn Truthtrance authority (FAQp8; supplied Advanced diff --git a/docs/TRUTHTRANCE_ALLIANCE_PROMISES.md b/docs/TRUTHTRANCE_ALLIANCE_PROMISES.md new file mode 100644 index 00000000..c9453d86 --- /dev/null +++ b/docs/TRUTHTRANCE_ALLIANCE_PROMISES.md @@ -0,0 +1,117 @@ +# Truthtrance current-Nexus alliance promises + +## Final-membership prototype — 26 October 2026 + +**Prototyped / Partial.** Ask whether another player will actually be allied +with one named partner when the current ordinary Nexus ends. Registered bare +native Basic and Advanced tables use their original reciprocal alliance quote. +The ordinary worm Nexus is separate from selecting the Nexus Cards module. +No start or publication gate changes. Earlier-phase/whole-turn diplomacy, +optional-module compositions and broader negotiated deals remain required. + +## Authority and provisional meaning + +General current-turn Truthtrance authority is recorded in the +[source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) and +[ordinary-bid contract](TRUTHTRANCE_BID_PROMISES.md); supplied Advanced physical39 +retains compliance while possible and later impossibility release. The supplied +physical15 ALLIANCES passage was read for this slice. It retains reciprocal +formation, own Nexus departure, at most one alliance and an opportunity to form +another pair. The existing [ordinary Nexus contract](NEXUS_OFFERS.md) and +`game/nexus-alliance.ts` remain authoritative native mechanics. + +Under standing14October choose-recommended priority, ask explicitly about the +**final actual partnership at this Nexus close**. Yes stays pending after a +real formation and fulfills only at closure. No requires ending without the +named partner; an interim alliance is allowed if ended before readiness. +An offer alone is not membership. The native actions still clear/readjudicate +ordinary readiness and retain all other players' original rights. + +The owned witness can retain an already held pair, accept a currently available +reciprocal invitation, or first break its own old pair if that invitation +remains. It never submits another player's action or assumes a future acceptance, +departure or renewed offer. Consent that is not available can produce No or +unknown where earlier own clauses do not fix the answer. Unknown keeps the +card: ask something else or save it to resume negotiations before a real offer. +Current consent does not lock the partner into remaining allied. A later +opposing withdrawal/break may make Yes impossible and release it. + +This interval/owned-consent/unknown/release protocol is a provisional online +composition of general current-turn authority, not a dedicated publisher +alliance-Truthtrance clarification. It does not certify full diplomacy or all +cross-player/faction/module interactions. + +## State and connected controls + +The original `truthAsk` carries `{kind:'alliance',target,turn,partner}`. Definite +answers bind a distinct event to the original internal Nexus interval; its +physical/source fingerprint is never projected. Only the respondent receives +feasible answers, its sanitized own records and native guide. Public history +names the respondent/partner and answer. Original declaration, priority, physical +card consumption, unknown/save and continuation remain. + +A definite answer reopens only its respondent's existing Nexus readiness. Ready +requires every live clause to match current membership. Actual closing settles +the clauses before either native draw or worm-ride continuation. A No stays live +through temporary membership. Own actions cannot destroy the last owned route; +opposing completed impossibility releases only incompatible answers in accepted +order. A closed interval never binds a later Nexus or turn. + +Private quotes reuse the same native pairing legality and preserve earlier +shipment, movement, revival and ordinary-payment obligations. An earlier owed, +currently funded bribe to the desired partner can be paid before acceptance; +its existing payment guide owns that step. Alliance guidance appears after +that real payment. Older foreign financial impossibility is reconciled with its +actual actor before alliance promises; keeping already matching membership +spends nothing and cannot inherit another failed debt. + +The fixed-turn partner fields, public final-status preview, target-only answers +and persistent panel offer actual break, accept or Ready. The guide rechecks +its current own event, public invitation/membership and clear Nexus window; +stale/foreign/modified packets reject. It works out of turn as ordinary Nexus +negotiation does. All four existing minimal policies use the same legal answer +and native guide. Strategy and difficulty calibration remain deferred. + +## Evidence and remaining gates + +Pure, original-deal/Worm/consent, UI and focused interaction cases live in +`tests/alliance-promises.test.ts`, `tests/fixture-truth-alliance.ts`, +`tests/truth-alliance-runtime.test.ts`, `tests/truth-alliance-controls.test.tsx` +and `tests/truth-alliance-interactions.test.ts`. Ten pure,13 native,six controls +and three interaction cases are included in the frozen **15-file,150/150** +affected union. `npm run check:quick`, `npm run build` and the private native +runtime smoke pass on the same unchanged source. A prior test-only TypeScript +narrowing failure was corrected before this passing v2 proof. + +Six native paths render the actual GameTable22 times and invoke ten actual +alliance callbacks plus one original bribe callback: + +| Original source | Prefix actions | Further actions | Actual result | +| --- | ---: | ---: | --- | +| Basic reciprocal invitation | 47 | 9 | Yes remains pending after acceptance and fulfills at Nexus close | +| Advanced reciprocal invitation | 48 | 12 | Same closure, with native continuation and four minimal policies using legal actions | +| Existing Basic pair | 48 | 9 | No uses the actual break before Ready and closure | +| Basic absent consent | 46 | 16 | Unknown/save, actual offer, same physical Truth redeclared, Yes and closure | +| Basic opposing withdrawal | 47 | 10 | Impossible Yes releases; reoffer does not rebind it and the old guide rejects | +| Basic earlier Bribe Yes | 47 | 12 | Actual payment3 fulfills its earlier promise, then acceptance/Ready closes the alliance clause | + +Sources select only still-unopened original deal/lot/Spice positions and scalar +lotteries, earn the real Worm Nexus and any original reciprocal consent, and +retain all33 original cards. No played hand, purse, clock, offer, alliance or +promise is installed. The two actor-order supplements explicitly control only +an inherited prior-debt record; their Nexus/answer is native. They establish +foreign financial release before evaluating a still-valid alliance clause, +without representing a broader historical-debt or assurance campaign. + +Frozen v2 report and verified manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-proof-v2/`. +Exact commands and logs are in `report.json`; native journeys, SSR HTML and the +runtime harness are in adjacent `20261026-truth-alliance-runtime/`. Final status +prose and one corrected rules-reference target are a separately recorded overlay +of that verified source. The reference test, types/lint and build pass again on +this final tree in adjacent `20261026-truth-alliance-reference-proof-v1/`; +unchanged rule tests and native journeys retain their v2 evidence. +SSR and actual callbacks are local engine/control evidence, not browser clicks. +No complete faction/mode, all combinations, recovery/privacy/custody, browser/ +mobile or deployed acceptance is certified. No saved-game/database/server, +reset/restart or deployment operation is performed. diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md index b07c5fa3..77bec3c0 100644 --- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md +++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md @@ -1,5 +1,10 @@ # Truthtrance nonbattle readiness and first implementation slice +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 +alliance-commitment absence entry for that interval; broader diplomacy remains. + The [ordinary-bribe follow-up](TRUTHTRANCE_BRIBE_PROMISES.md) adds a funded current-turn named-recipient payment commitment. Its cumulative payer progress and original deferred receipt supersede the historical bribe absence entry; diff --git a/game/alliance-promises.ts b/game/alliance-promises.ts new file mode 100644 index 00000000..0d12f4b6 --- /dev/null +++ b/game/alliance-promises.ts @@ -0,0 +1,134 @@ +import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope'; +import { NexusAllianceError, quoteNexusAlliance } from './nexus-alliance'; + +export type AllianceClaim = { turn: number; partner: string }; +export type AllianceOpportunity = { turn: number; source: string }; +export type AlliancePromise = AllianceClaim & { + event: string; + /** Internal binding to the original Nexus interval, not public question text. */ + source: string; + player: string; + asker: string; + answer: boolean; + fulfilled?: boolean; + released?: boolean; +}; +type AllianceContext = { + players: readonly { id: string; ally: string | null }[]; + offers: Readonly>; +}; +type AllianceAction = { type: 'alliance'; target: string | null }; + +export class AlliancePromiseError extends Error { + constructor(message: string) { + super(message); + this.name = 'AlliancePromiseError'; + } +} + +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 parseAllianceClaim(value: unknown): AllianceClaim { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new AlliancePromiseError('Choose a final-alliance question for the current Nexus.'); + const input = value as Record; + if (typeof input.turn !== 'number' || !Number.isSafeInteger(input.turn) || input.turn < 1 || !identifier(input.partner)) + throw new AlliancePromiseError('Choose the current turn and exact alliance partner.'); + return { turn: input.turn, partner: input.partner }; +} + +/** This is the original bare native context. An ordinary worm Nexus does not + * require selecting the optional Nexus Cards module or change a start gate. */ +export function alliancePromiseModeSupported(game: NativePromiseScope & { + semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown; + richeseBetrayalPreview?: unknown; +}): boolean { + return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview && + !game.nexusKullPreview && !game.richeseBetrayalPreview; +} + +export function allianceClaimText(claim: AllianceClaim, partnerName: (id: string) => string = id => id): string { + return `be allied with ${partnerName(claim.partner)} when this Nexus ends`; +} + +export function liveAlliancePromises(promises: readonly AlliancePromise[], player: string, + opportunity: AllianceOpportunity): AlliancePromise[] { + return promises.filter(promise => promise.player === player && promise.turn === opportunity.turn && + promise.source === opportunity.source && !promise.fulfilled && !promise.released); +} + +export function alliancePromiseRequirements(promises: readonly AlliancePromise[], player: string, + opportunity: AllianceOpportunity): { partner: string | null; forbidden: string[] } | null { + let partner: string | null = null; + const forbidden = new Set(); + for (const promise of liveAlliancePromises(promises, player, opportunity)) { + if (promise.answer) { + if (partner !== null && partner !== promise.partner) return null; + partner = promise.partner; + } else forbidden.add(promise.partner); + } + return partner !== null && forbidden.has(partner) ? null : { partner, forbidden: [...forbidden] }; +} + +/** This describes final membership only. Temporary proposals or membership do + * not settle a No; the engine owns the actual Nexus closing event. */ +export function alliancePromisesMatch(promises: readonly AlliancePromise[], player: string, + opportunity: AllianceOpportunity, ally: string | null): boolean { + return liveAlliancePromises(promises, player, opportunity) + .every(promise => (ally === promise.partner) === promise.answer); +} + +/** Provisional owned-action witness. Never propose on another player's behalf + * or assume a later acceptance, departure or renewed offer. */ +export function findAlliancePromisePlan(promises: readonly AlliancePromise[], player: string, + opportunity: AllianceOpportunity, context: AllianceContext): AllianceAction[] | null { + const requirements = alliancePromiseRequirements(promises, player, opportunity); + if (!requirements) return null; + let players = context.players.map(seat => ({ ...seat })); + let offers = { ...context.offers }; + const owner = players.find(seat => seat.id === player); + if (!owner) return null; + if (alliancePromisesMatch(promises, player, opportunity, owner.ally)) return []; + const actions: AllianceAction[] = []; + const quote = (target: string | null) => { + const result = quoteNexusAlliance({ players, offers, actor: player, target }); + players = players.map(seat => ({ ...seat, ally: result.allies[seat.id] })); + offers = result.offers; + actions.push({ type: 'alliance', target }); + }; + try { + if (requirements.partner === null) { + if (owner.ally !== null && requirements.forbidden.includes(owner.ally)) quote(null); + } else { + const desired = players.find(seat => seat.id === requirements.partner); + if (!desired || desired.ally !== null || offers[desired.id] !== player) return null; + if (owner.ally !== null) quote(null); + // Breaking the owner's old pair may change offers. Use only consent + // retained by the canonical quote, not the original copied proposal. + if (offers[desired.id] !== player) return null; + quote(desired.id); + } + } catch (error) { + if (error instanceof NexusAllianceError) return null; + throw error; + } + return alliancePromisesMatch(promises, player, opportunity, players.find(seat => seat.id === player)!.ally) + ? actions : null; +} + +export function validateAlliancePromises(promises: readonly AlliancePromise[], players: readonly string[]): void { + if (!Array.isArray(promises)) throw new AlliancePromiseError('The alliance commitment record is invalid.'); + const events = new Set(); + for (const promise of promises) { + parseAllianceClaim(promise); + if (!identifier(promise.event) || events.has(promise.event) || !identifier(promise.source) || + !identifier(promise.player) || !identifier(promise.asker) || + !players.includes(promise.player) || !players.includes(promise.asker) || !players.includes(promise.partner) || + promise.player === promise.asker || promise.player === promise.partner || typeof promise.answer !== 'boolean' || + !optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) || promise.fulfilled && promise.released) + throw new AlliancePromiseError('The alliance commitment lost its Nexus source, event or participants.'); + events.add(promise.event); + } +} diff --git a/game/bots.ts b/game/bots.ts index cf63bcb2..8d6eaff1 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1308,6 +1308,7 @@ function policyActions(g: GameView): Action[] { : []; } if (g.bribePromiseGuide) return [g.bribePromiseGuide.action]; + if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action]; if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action]; if (g.bidPromiseGuide) return [g.bidPromiseGuide.action]; if (g.truthtrance) { @@ -1316,6 +1317,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 === 'alliance') { + const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'bribe') { const answer = g.truthBribeAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; diff --git a/game/engine.ts b/game/engine.ts index 096b2332..ae6c9e02 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,5 @@ +import { AlliancePromiseError, alliancePromiseModeSupported, liveAlliancePromises, alliancePromiseRequirements, + alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises'; import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe, bribePromiseRequirements, validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises'; import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; @@ -2142,6 +2144,7 @@ export type Game = { bidPromises?: BidPromise[]; revivalPromises?: RevivalPromise[]; bribePromises?: BribePromise[]; + alliancePromises?: AlliancePromise[]; movementPromises?: MovementPromise[]; /** Server-only certainty of movement-relevant cards still in the discard. */ movementDiscardKnowledge?: MovementDiscardKnowledge; @@ -14895,6 +14898,157 @@ function pledgeAid(g: Game, p: Player, requested: unknown) { g.aid[p.id] = { recipient: p.ally, amount }; log(g, `${p.name} updated the spice available to their ally this phase.`); } +function alliancePromiseRule(calculate: () => T): T { + try { return calculate(); } + catch (error) { if (error instanceof AlliancePromiseError) throw new RuleError(error.message); throw error; } +} +function alliancePromiseIntegrity(g: Game) { + alliancePromiseRule(() => validateAlliancePromises(g.alliancePromises ?? [], g.players.map(player => player.id))); +} +function allianceOpportunity(g: Game): AllianceOpportunity | null { + if (g.status !== 'playing' || g.phase !== 1 || !g.nexus || !alliancePromiseModeSupported(g)) return null; + return { turn: g.turn, source: JSON.stringify(['ordinaryNexus', g.turn, + g.spiceDiscard.map(pile => pile.length), g.spiceSequence?.pile ?? null, g.spiceSequence?.skipped?.length ?? 0]) }; +} +function allianceQuestionWindow(g: Game): boolean { + return !!allianceOpportunity(g) && !g.spiceWindow && !g.spiceResolution && !g.response && + !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama; +} +/** The original native reciprocal quote owns identity and pairing legality. */ +function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) { + if (target !== null) { + const other = getPlayer(g, target); + requireRule(other.id !== p.id, 'Choose another player.'); + requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn) + .some(promise => promise.answer && promise.recipient === other.id), + 'Pay your promised ordinary bribe before offering that recipient an alliance.'); + const blocked = homeworldAllianceReason(g, p.id, other.id); + requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); + } + return nexusRule(() => quoteNexusAlliance({ players: g.players.map(player => ({ id: player.id, ally: player.ally })), + offers: g.allianceOffers, actor: p.id, target })); +} +function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?? []) { + const opportunity = allianceOpportunity(g); + if (!opportunity) return null; + const actions = findAlliancePromisePlan(promises, p.id, opportunity, { players: g.players, offers: g.allianceOffers }); + if (!actions) return null; + // Keeping an already matching membership spends nothing and changes no + // alliance. An unrelated foreign loss is not a prospective cost of Ready. + if (!actions.length) return { actions, bribePreparation: false }; + const trial = structuredClone(g), actor = getPlayer(trial, p.id); + trial.truthtrance = null; + let bribePreparation = false; + try { + // An already owed, currently payable bribe may precede accepting its named + // recipient. Keep that payment on its existing guide and native action. + const accepts = actions.find(action => action.target !== null)?.target; + if (accepts && liveBribePromises(trial.bribePromises ?? [], p.id, g.turn) + .some(promise => promise.answer && promise.recipient === accepts)) { + const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts); + if (!payment) return null; + const amount = payment.amount as number; + actor.spice -= amount; + getPlayer(trial, accepts).bribes += amount; + trial.bribePromises = bribePromiseRule(() => recordPromisedBribe(trial.bribePromises ?? [], p.id, + g.turn, { recipient: accepts, amount })); + bribePreparation = true; + } + for (const action of actions) { + const quote = normalNexusAllianceQuote(trial, actor, action.target); + for (const player of trial.players) player.ally = quote.allies[player.id]; + trial.allianceOffers = quote.offers; + if (quote.formed) for (const id of quote.formed) getPlayer(trial, id).allySinceTurn = g.turn; + trial.ready = []; + } + const voluntary = { actor: p.id, action: { type: 'alliance' } }; + reconcileShipmentPromises(trial, voluntary); + reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase); + reconcileRevivalPromises(trial, voluntary); + reconcileBribePromises(trial, voluntary); + return { actions, bribePreparation }; + } catch (error) { if (error instanceof RuleError) return null; throw error; } +} +function allianceTruthAnswers(g: Game, p: Player, claim: AllianceClaim): ('yes' | 'no' | 'unknown')[] { + const opportunity = allianceOpportunity(g); + if (!opportunity || !allianceQuestionWindow(g) || claim.turn !== g.turn || claim.partner === p.id || + !g.players.some(player => player.id === claim.partner)) return []; + const prior = alliancePromiseRequirements(g.alliancePromises ?? [], p.id, opportunity); + const answers: ('yes' | 'no' | 'unknown')[] = (['yes', 'no'] as const).filter(answer => + !!alliancePromisePlan(g, p, [...(g.alliancePromises ?? []), { ...claim, event: 'candidate', source: opportunity.source, + player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }])); + const foreignConsent = p.ally !== claim.partner && + (getPlayer(g, claim.partner).ally !== null || g.allianceOffers[claim.partner] !== p.id); + if (!answers.includes('yes') && foreignConsent && prior?.partner === null && + !prior.forbidden.includes(claim.partner)) answers.push('unknown'); + return answers; +} +function bindAllianceTruth(g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) { + const opportunity = allianceOpportunity(g); + requireRule(opportunity && allianceTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose an alliance answer compatible with actual consent and earlier answers.'); + (g.alliancePromises ??= []).push({ ...claim, event: crypto.randomUUID(), source: opportunity.source, + player: p.id, asker, answer }); + if (g.ready.includes(p.id)) { + g.ready = g.ready.filter(player => player !== p.id); + log(g, `${p.name} has a new Truthtrance alliance commitment and must confirm Nexus readiness again.`); + } +} +function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action: Action }) { + if (!g.alliancePromises?.some(promise => !promise.fulfilled && !promise.released)) return; + alliancePromiseIntegrity(g); + if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.spiceWindow || g.spiceResolution || + g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return; + const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = []; + for (const promise of g.alliancePromises) { + if (promise.fulfilled || promise.released) continue; + const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source; + const player = getPlayer(g, promise.player), matches = (player.ally === promise.partner) === promise.answer; + const possible = same && alliancePromisePlan(g, player, [...kept, promise]); + if (!same && matches) { promise.fulfilled = true; continue; } + if (!possible) { + requireRule(voluntary?.actor !== promise.player, + 'You cannot voluntarily evade your Truthtrance alliance answer.'); + promise.released = true; + log(g, `${player.name} can no longer fulfill the Truthtrance alliance answer; that answer is no longer binding.`); + } else { + kept.push(promise); + if (!matches && g.ready.includes(player.id)) g.ready = g.ready.filter(id => id !== player.id); + } + } +} +function guardAlliancePromiseReady(g: Game, p: Player) { + const opportunity = allianceOpportunity(g); + if (!opportunity) return; + requireRule(alliancePromisesMatch(g.alliancePromises ?? [], p.id, opportunity, p.ally), + 'Match your Truthtrance alliance answers before finishing this Nexus.'); +} +function settleAlliancePromises(g: Game) { + const opportunity = allianceOpportunity(g); + if (!opportunity) return; + for (const player of g.players) guardAlliancePromiseReady(g, player); + for (const promise of g.alliancePromises ?? []) + if (promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.fulfilled && !promise.released) + promise.fulfilled = true; +} +function projectedAlliancePromises(g: Game, player: string) { + return (g.alliancePromises ?? []).filter(promise => promise.player === player).map(promise => ({ + event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn, + partner: promise.partner, answer: promise.answer, + ...(promise.fulfilled === undefined ? {} : { fulfilled: promise.fulfilled }), + ...(promise.released === undefined ? {} : { released: promise.released }), + })); +} +function projectedAlliancePromiseGuide(g: Game, player: string): { action: Action } | null { + const opportunity = allianceOpportunity(g); + if (!opportunity || !allianceQuestionWindow(g) || g.truthtrance || g.ready.includes(player)) return null; + const promise = liveAlliancePromises(g.alliancePromises ?? [], player, opportunity)[0]; + if (!promise) return null; + const plan = alliancePromisePlan(g, getPlayer(g, player)); + if (!plan || plan.bribePreparation) return null; + const action: Action = plan.actions[0] ?? { type: 'ready' }; + return { action: { ...action, alliancePromiseEvent: promise.event } }; +} function bribePromiseRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof BribePromiseError) throw new RuleError(error.message); throw error; } @@ -18387,7 +18541,7 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio source.producer === 'groundMovement' && source.mover === p.id)) continue; const early = g.phase < 5 || sourcePhase < 5; const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action && - (['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || + (['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 || action.type === 'ready' && [2, 4].includes(sourcePhase))); const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || @@ -26891,7 +27045,7 @@ function reconcileShipmentPromises( voluntary.action.type === 'richeseBid' && typeof voluntary.action.amount === 'number' && voluntary.action.amount > 0 || voluntary.action.type === 'ready' && [2, 4].includes(voluntary.phase ?? g.phase)); const ownVoluntary = voluntary?.actor === p.id && - (['card', 'bribe', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) || + (['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) || earlyVoluntary || future && voluntary.action.type === 'decision'); // A foreign pending parent may spend or refund its approved funding. Wait @@ -34946,6 +35100,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { bidPromiseIntegrity(state); revivalPromiseIntegrity(state); bribePromiseIntegrity(state); + alliancePromiseIntegrity(state); revivalOrderIntegrity(state); state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); @@ -35049,6 +35204,12 @@ export function applyAction(state: Game, id: string, action: Action): Game { ); return normalizeAutomaticGame(state); } + if (action.alliancePromiseEvent !== undefined) { + const guide = projectedAlliancePromiseGuide(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 alliance commitment guide is stale or belongs to another Nexus choice.'); + } if (action.bribePromiseEvent !== undefined) { const guide = projectedBribePromiseGuide(state, id); requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && @@ -35189,6 +35350,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { reconcileBidPromises(g, { actor: id, action }); reconcileRevivalPromises(g, { actor: id, action }); reconcileBribePromises(g, { actor: id, action }); + reconcileAlliancePromises(g, { actor: id, action }); settleAutomaticContinuations(g); observeOccupation(g); reconcileHomeworldEcazDuke(g); @@ -35393,6 +35555,7 @@ function settleAutomaticContinuations(g: Game) { reconcileRevivalPromises(g); reconcileBribePromises(g); reconcileGroundMovementPromises(g); + reconcileAlliancePromises(g); reconcileTraitorTruth(g); requireRule( JSON.stringify(g) !== before, @@ -35408,6 +35571,7 @@ export function normalizeAutomaticGame(state: Game): Game { bidPromiseIntegrity(state); revivalPromiseIntegrity(state); bribePromiseIntegrity(state); + alliancePromiseIntegrity(state); state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); harkonnenNexusBetrayalIntegrity(state); @@ -35495,6 +35659,7 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileRevivalPromises(g); reconcileBribePromises(g); reconcileGroundMovementPromises(g); + reconcileAlliancePromises(g); reconcileTraitorTruth(g); settleAutomaticContinuations(g); observeOccupation(g); @@ -35739,6 +35904,8 @@ function applyActionInner( bindShipment: bindShipmentTruth, movementAnswers: movementTruthAnswers, bindMovement: bindMovementTruth, + allianceAnswers: allianceTruthAnswers, + bindAlliance: bindAllianceTruth, bribeAnswers: bribeTruthAnswers, bindBribe: bindBribeTruth, revivalAnswers: revivalTruthAnswers, @@ -35754,7 +35921,7 @@ function applyActionInner( ) return g; } catch (error) { - if (error instanceof TruthError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) + 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) throw new RuleError(error.message); throw error; } @@ -38100,29 +38267,8 @@ function applyActionInner( const formerAlly = p.ally ? getPlayer(g, p.ally).name : null; const formerOffer = g.allianceOffers[id]; const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name; - let other: Player | null = null; - if (target !== null) { - other = getPlayer(g, target); - requireRule(other.id !== id, 'Choose another player.'); - requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn) - .some(promise => promise.answer && promise.recipient === other!.id), - 'Pay your promised ordinary bribe before offering that recipient an alliance.'); - const blocked = homeworldAllianceReason(g, id, other.id); - requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); - } - const quote = nexusRule(() => - quoteNexusAlliance( - { - players: g.players.map((player) => ({ - id: player.id, - ally: player.ally, - })), - offers: g.allianceOffers, - actor: id, - target, - }, - ), - ); + const other = target === null ? null : getPlayer(g, target); + const quote = normalNexusAllianceQuote(g, p, target); for (const player of g.players) player.ally = quote.allies[player.id]; g.allianceOffers = quote.offers; if (target === null) { @@ -39488,6 +39634,7 @@ function applyActionInner( return g; } if (t === 'ready') { + 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); @@ -39505,6 +39652,9 @@ function applyActionInner( } g.ready.push(id); if (g.ready.length === g.players.length) { + // Every quiet negotiation closes before its native continuation, whether + // that continuation draws Spice or begins worm rides. + if (g.phase === 1 && allianceQuestionWindow(g)) settleAlliancePromises(g); if (g.phase === 0) startStormMovement(g, g.stormPending!); else if (g.phase === 1 && g.spiceWindow) finishSpiceWindow(g); else if (g.phase === 1 && (!g.nexus || g.summonedBeforeBlow)) @@ -39684,6 +39834,12 @@ export function viewGame(state: Game, id: string) { }).map(player => player.id) : [], truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null, + alliancePromiseNexus: (() => { const nexus = allianceOpportunity(g); return nexus ? { turn: nexus.turn } : null; })(), + truthAllianceTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [], + truthAllianceAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'alliance' ? allianceTruthAnswers(g, me, g.truthtrance.question) : null, + alliancePromises: projectedAlliancePromises(g, id), + alliancePromiseGuide: projectedAlliancePromiseGuide(g, id), truthBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [], truthBribeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'bribe' ? bribeTruthAnswers(g, me, g.truthtrance.question) : null, diff --git a/game/reference.ts b/game/reference.ts index b7691d2b..a25b67df 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] }, ], }, + { + id: 'truthtrance-alliance', + title: 'Truthtrance: alliance membership at this Nexus close', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Bind actual partnership at the current ordinary Nexus close, using real reciprocal consent and original alliance actions.', + steps: [ + 'Choose Bind alliance membership at this Nexus close, another respondent and a named partner. This first version supports a quiet ordinary Nexus in registered native Basic and Advanced tables without optional modules. An ordinary worm Nexus does not require the Nexus Cards option.', + 'The question concerns actual final membership at this Nexus close. A proposal alone is not an alliance. Yes stays live after forming the pair and settles only at the close. No requires ending without the named partner; a temporary partnership may be broken before readiness.', + 'An owned Yes route uses an already held partnership or a currently available reciprocal invitation. The respondent may break an old pair first if the real invitation remains. Another player’s future consent, departure or renewed offer is not invented. Other players keep their original right to withdraw or break.', + 'If consent is not available and earlier answers do not fix the result, No or I don’t know may be available. Unknown preserves the physical card; the holder may ask a different question or save it to resume negotiations, then ask again after a real invitation.', + 'Your persistent panel offers the next native break, acceptance or Ready action in a quiet Nexus, including out of turn. A new definite answer reopens your own readiness. Ready requires the current partnership to match every live answer. Actual closing settles them before the next native draw or worm ride.', + 'Own actions cannot erase the last owned completion of Yes. A completed opposing withdrawal or break can release an impossible answer; earlier compatible answers remain. Existing financial and movement commitments are retained. An earlier owed bribe to the desired partner uses its original payment guide before acceptance.', + 'The current-Nexus interval, owned-consent witness and release priority are provisional online compositions of general current-turn Truthtrance authority. Wider negotiations, earlier-phase alliance questions, optional modules and complete-mode acceptance remain due. No start gate opens.', + ], + related: ['card-truthtrance', 'nexus-offers', phaseRuleId(1), 'truthtrance-bribe', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Current-Nexus final membership, joint own Yes/No, canonical break/accept quotes, real closure and opposing-impossibility release retain original consent rights.', evidence: ['game/alliance-promises.ts', 'game/nexus-alliance.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/alliance-promises.test.ts', 'tests/truth-alliance-runtime.test.ts', 'tests/truth-alliance-interactions.test.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Fixed-turn named partner, public final-status preview, target-only answers and persistent event-bound break/accept/Ready guidance. No private Nexus source is projected.', evidence: ['components/alliance-claim-fields.tsx', 'components/alliance-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-alliance-controls.test.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four minimal policies use feasible answers and owned native guides. Strategic negotiation and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-alliance-runtime.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Physical alliance/general Truthtrance sources, owned consent, final interval, unknown/save and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_ALLIANCE_PROMISES.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Original setup/Worm/consent histories and focused UI/pure/actor-order cases; wider source compositions, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-alliance.ts', 'tests/truth-alliance-runtime.test.ts', 'tests/truth-alliance-controls.test.tsx', 'tests/truth-alliance-interactions.test.ts'] }, + ], + }, { id: 'truthtrance-bribe', title: 'Truthtrance: ordinary bribe-payment promises', @@ -3432,7 +3457,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, 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, 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.', @@ -3444,6 +3469,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.', 'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.', '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.', '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.', @@ -3465,6 +3491,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe', + 'truthtrance-alliance', 'richese-no-field', 'card-reinforcements', ], diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 02dbceca..f3206837 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +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'; import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises'; @@ -62,6 +63,7 @@ export type TruthFact = | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'and' | 'or'; terms: TruthFact[] }; export type TruthQuestion = + | ({ kind: 'alliance'; target: string } & AllianceClaim) | ({ kind: 'bribe'; target: string } & BribeClaim) | ({ kind: 'revival'; target: string } & RevivalClaim) | ({ kind: 'bid'; target: string } & BidClaim) @@ -216,6 +218,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { g.players.some((p) => p.id === v.target), 'Ask one other player.', ); + if (v.kind === 'alliance') { + check(Object.keys(v).sort().join(',') === 'kind,partner,target,turn', + 'Supply the current Nexus turn, named alliance partner and respondent.'); + const claim = parseAllianceClaim(v); + check(alliancePromiseModeSupported(g) && g.status === 'playing' && g.phase === 1 && g.nexus && + !g.spiceWindow && !g.spiceResolution && !g.response && !g.decision && !g.phaseOpening && + !g.pendingTreacheryDiscard && !g.pendingKarama && claim.turn === g.turn && + claim.partner !== v.target && g.players.some(player => player.id === claim.partner), + 'Choose the current ordinary Nexus and another named partner in a supported table.'); + return { kind: 'alliance', target: v.target, ...claim }; + } if (v.kind === 'bribe') { check(Object.keys(v).sort().join(',') === 'kind,minimum,recipient,target,turn', 'Supply the current turn, named bribe recipient, minimum and respondent.'); @@ -475,6 +488,7 @@ export function truthQuestionText( playerName: (id: string) => string = id => id, ): string { if (q.kind === 'freeform') return q.text; + 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)}?`; if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`; @@ -523,6 +537,8 @@ export function resolveTruthAction( ) => void; movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[]; bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void; + allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[]; + 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; revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[]; @@ -647,6 +663,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 === '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.'); if (w.question.kind === 'bribe') check(effects.bribeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a current bribe question compatible with earlier answers.'); @@ -706,6 +725,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 === '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.'); + if (a.answer !== 'unknown') effects.bindAlliance(g, p, q, a.answer === 'yes', current.player); + } if (q.kind === 'bribe') { check(effects.bribeAnswers && effects.bindBribe && effects.bribeAnswers(g, p, q).includes(a.answer), 'Choose an answer consistent with actual ordinary bribe funding and earlier promises.'); diff --git a/tests/alliance-promises.test.ts b/tests/alliance-promises.test.ts new file mode 100644 index 00000000..ca919a9d --- /dev/null +++ b/tests/alliance-promises.test.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + AlliancePromiseError, + allianceClaimText, + alliancePromiseModeSupported, + alliancePromiseRequirements, + alliancePromisesMatch, + findAlliancePromisePlan, + liveAlliancePromises, + parseAllianceClaim, + validateAlliancePromises, + type AllianceOpportunity, + type AlliancePromise, +} from '../game/alliance-promises'; +import { quoteNexusAlliance } from '../game/nexus-alliance'; + +const seats = ['respondent', 'partner-a', 'partner-b', 'asker'] as const; +const opportunity = (): AllianceOpportunity => ({ turn: 2, source: 'original-worm-nexus' }); +function promise(event: string, partner = 'partner-a', extra: Partial = {}): AlliancePromise { + return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', + partner, answer: true, ...extra }; +} +function context() { + return { players: seats.map(id => ({ id, ally: null as string | null })), offers: {} as Record }; +} +function executePlan(state: ReturnType, actions: { type: 'alliance'; target: string | null }[]) { + let current = structuredClone(state); + for (const action of actions) { + const quote = quoteNexusAlliance({ ...current, actor: 'respondent', target: action.target }); + current = { players: current.players.map(player => ({ ...player, ally: quote.allies[player.id] })), offers: quote.offers }; + } + return current; +} + +void test('alliance claims normalize current-turn partner identity without retaining private Nexus sources', () => { + const input = { turn: 2, partner: 'partner-a', source: 'private-source', event: 'private-event' }; + assert.deepEqual(parseAllianceClaim(input), { turn: 2, partner: 'partner-a' }); + assert.deepEqual(parseAllianceClaim({ turn: Number.MAX_SAFE_INTEGER, partner: 'partner-a' }), + { turn: Number.MAX_SAFE_INTEGER, partner: 'partner-a' }); + for (const turn of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined]) + assert.throws(() => parseAllianceClaim({ ...input, turn }), AlliancePromiseError); + for (const partner of ['', ' ', ' partner-a', 'partner-a ', 1, null, undefined]) + assert.throws(() => parseAllianceClaim({ ...input, partner }), AlliancePromiseError); + for (const value of [null, undefined, [], 'partner-a', 2]) assert.throws(() => parseAllianceClaim(value), AlliancePromiseError); + const text = allianceClaimText(parseAllianceClaim(input), id => id === 'partner-a' ? 'Atreides' : id); + assert.match(text, /be allied with Atreides when this Nexus ends/); + assert.doesNotMatch(text, /private-source|private-event/); +}); + +void test('alliance promises retain bare native Basic and Advanced scope and do not require optional Nexus Cards', () => { + 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 })) }; + for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false }, + { ...classic, expansions: ['ix', 'choam', 'ecaz'] }, + { ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] }, + ]) assert.equal(alliancePromiseModeSupported(game), true); + for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards', + 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery', + 'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const) + assert.equal(alliancePromiseModeSupported({ ...native, [option]: true }), false, option); + for (const game of [ + { ...native, expansions: ['ix', 'choam'] }, { ...native, expansions: ['ix', 'ix', 'ecaz'] }, + { ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] }, + { ...classic, players: [{ faction: 'atreides' }] }, { ...classic, players: [...native.players, { faction: 'atreides' }] }, + { ...classic, advanced: 'basic' as unknown as boolean }, + ]) assert.equal(alliancePromiseModeSupported(game), false); +}); + +void test('joint answers require one distinct Yes partner while independent No partners remain forbidden', () => { + const records = [promise('yes-a'), promise('yes-a-again'), + promise('no-b', 'partner-b', { answer: false }), promise('no-b-again', 'partner-b', { answer: false }), + promise('no-asker', 'asker', { answer: false })]; + assert.deepEqual(alliancePromiseRequirements(records, 'respondent', opportunity()), + { partner: 'partner-a', forbidden: ['partner-b', 'asker'] }); + assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true); + assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), false); + assert.equal(alliancePromiseRequirements([...records, promise('yes-b', 'partner-b')], 'respondent', opportunity()), null); + assert.equal(alliancePromiseRequirements([...records, promise('no-a', 'partner-a', { answer: false })], 'respondent', opportunity()), null); + assert.equal(findAlliancePromisePlan([...records, promise('yes-b', 'partner-b')], 'respondent', opportunity(), context()), null); +}); + +void test('an unallied Yes target requires actual outgoing consent and never assumes a future foreign break or acceptance', () => { + const records = [promise('yes-a')]; const none = context(); + assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), none), null); + const ownOffer = { ...none, offers: { respondent: 'partner-a' } }; + assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), ownOffer), null, + 'An own proposal does not control the other player’s future acceptance'); + const incoming = { ...none, offers: { 'partner-a': 'respondent' } }; + const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), incoming); + assert.deepEqual(plan, [{ type: 'alliance', target: 'partner-a' }]); assert.ok(plan); + const completed = executePlan(incoming, plan); + assert.equal(completed.players.find(player => player.id === 'respondent')!.ally, 'partner-a'); + const occupied = structuredClone(incoming); + occupied.players.find(player => player.id === 'partner-a')!.ally = 'partner-b'; + occupied.players.find(player => player.id === 'partner-b')!.ally = 'partner-a'; + assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), occupied), null, + 'Even an outstanding offer does not authorize breaking the desired partner’s current alliance'); + assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), { ...none, offers: { 'partner-a': 'asker' } }), null); +}); + +void test('an owned break can precede acceptance only while the desired partner offer survives the canonical quotes', () => { + const state = context(); + state.players.find(player => player.id === 'respondent')!.ally = 'partner-b'; + state.players.find(player => player.id === 'partner-b')!.ally = 'respondent'; + state.offers = { respondent: 'asker', 'partner-a': 'respondent', 'partner-b': 'asker', asker: 'partner-b' }; + const before = structuredClone(state); state.players.forEach(Object.freeze); Object.freeze(state.players); + Object.freeze(state.offers); Object.freeze(state); + const records = [promise('yes-a'), promise('no-b', 'partner-b', { answer: false })]; + const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), state); + assert.deepEqual(plan, [{ type: 'alliance', target: null }, { type: 'alliance', target: 'partner-a' }]); assert.ok(plan); + const completed = executePlan(state, plan); + assert.deepEqual(completed.players.map(player => [player.id, player.ally]), [ + ['respondent', 'partner-a'], ['partner-a', 'respondent'], ['partner-b', null], ['asker', null], + ]); + assert.deepEqual(completed.offers, { 'partner-b': 'asker', asker: 'partner-b' }, + 'An unrelated reciprocal proposal is not executed on either foreign player’s behalf'); + assert.deepEqual(state, before); +}); + +void test('No constrains final membership and permits temporary proposals or a currently different alliance', () => { + const records = [promise('no-a', 'partner-a', { answer: false })]; + const unallied = { ...context(), offers: { respondent: 'partner-a' } }; + assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), true); + assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), unallied), [], + 'A proposal itself is not final alliance membership'); + const other = context(); + other.players.find(player => player.id === 'respondent')!.ally = 'partner-b'; + other.players.find(player => player.id === 'partner-b')!.ally = 'respondent'; + assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), other), []); + const forbidden = context(); + forbidden.players.find(player => player.id === 'respondent')!.ally = 'partner-a'; + forbidden.players.find(player => player.id === 'partner-a')!.ally = 'respondent'; + assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), false); + const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), forbidden); + assert.deepEqual(plan, [{ type: 'alliance', target: null }]); assert.ok(plan); + const final = executePlan(forbidden, plan).players.find(player => player.id === 'respondent')!.ally; + assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), final), true); +}); + +void test('an already held required partner needs no action but a foreign break removes the owned completion until renewed consent', () => { + const state = context(); + state.players.find(player => player.id === 'respondent')!.ally = 'partner-a'; + state.players.find(player => player.id === 'partner-a')!.ally = 'respondent'; + const records = [promise('yes-a')]; + assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), state), []); + const broken = quoteNexusAlliance({ ...state, actor: 'partner-a', target: null }); + const afterBreak = { players: state.players.map(player => ({ ...player, ally: broken.allies[player.id] })), offers: broken.offers }; + assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), false); + assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), afterBreak), null); + const renewed = quoteNexusAlliance({ ...afterBreak, actor: 'partner-a', target: 'respondent' }); + const afterOffer = { players: afterBreak.players.map(player => ({ ...player, ally: renewed.allies[player.id] })), offers: renewed.offers }; + assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), afterOffer), [{ type: 'alliance', target: 'partner-a' }]); + assert.equal(records[0].fulfilled, undefined); assert.equal(records[0].released, undefined, + 'Planning never records the real interval’s completion or forced release'); +}); + +void test('turn, Nexus source, respondent and terminal records isolate the current final-membership obligation', () => { + const live = promise('live'); + const records = [live, promise('other-turn', 'partner-b', { turn: 1 }), + promise('other-source', 'partner-b', { source: 'different-worm-same-turn' }), + promise('other-player', 'partner-b', { player: 'partner-a' }), + promise('fulfilled', 'partner-b', { fulfilled: true }), promise('released', 'partner-b', { released: true })]; + const before = structuredClone(records); records.forEach(Object.freeze); Object.freeze(records); + assert.deepEqual(liveAlliancePromises(records, 'respondent', opportunity()), [live]); + assert.deepEqual(alliancePromiseRequirements(records, 'respondent', opportunity()), { partner: 'partner-a', forbidden: [] }); + assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true); + assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-b'), false); + assert.deepEqual(alliancePromiseRequirements(records, 'absent', opportunity()), { partner: null, forbidden: [] }); + assert.equal(alliancePromisesMatch(records, 'absent', opportunity(), null), true); + assert.deepEqual(records, before); +}); + +void test('no clauses or only terminal answers need no owned change and unknown actors cannot receive a plan', () => { + assert.deepEqual(findAlliancePromisePlan([], 'respondent', opportunity(), context()), []); + const records = [promise('old-yes', 'partner-a', { fulfilled: true }), + promise('released-no', 'partner-b', { answer: false, released: true })]; + assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), context()), []); + assert.equal(findAlliancePromisePlan([], 'absent', opportunity(), context()), null); + assert.deepEqual(alliancePromiseRequirements([promise('no-a', 'partner-a', { answer: false }), + promise('no-b', 'partner-b', { answer: false })], 'respondent', opportunity()), + { partner: null, forbidden: ['partner-a', 'partner-b'] }); +}); + +void test('record validation retains original interval, event and participants without rechecking historical terminal membership', () => { + const valid = [promise('live'), promise('fulfilled-yes', 'partner-b', { fulfilled: true }), + promise('released-yes', 'partner-a', { released: true }), + promise('fulfilled-no', 'partner-b', { answer: false, fulfilled: true }), + promise('released-no', 'partner-a', { answer: false, released: true }), + promise('asker-is-partner', 'asker')]; + const before = structuredClone(valid); + assert.doesNotThrow(() => validateAlliancePromises(valid, seats)); assert.deepEqual(valid, before); + assert.doesNotThrow(() => validateAlliancePromises(JSON.parse(JSON.stringify(valid)) as AlliancePromise[], seats)); + assert.doesNotThrow(() => validateAlliancePromises([], seats)); + const base = promise('event'); + const invalid: unknown[] = [null, {}, [null], [base, { ...base }], + ...[{ event: '' }, { event: ' ' }, { source: '' }, { source: ' ' }, + { player: 'outsider' }, { asker: 'outsider' }, { asker: 'respondent' }, + { partner: 'outsider' }, { partner: 'respondent' }, { partner: ' partner-a' }, + { answer: 'yes' }, { fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true }, + { turn: 0 }].map(extra => [{ ...base, ...extra }])]; + for (const records of invalid) + assert.throws(() => validateAlliancePromises(records as AlliancePromise[], seats), AlliancePromiseError); + assert.deepEqual(valid, before); +}); diff --git a/tests/fixture-truth-alliance.ts b/tests/fixture-truth-alliance.ts new file mode 100644 index 00000000..d2f69bb0 --- /dev/null +++ b/tests/fixture-truth-alliance.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { + actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer, type TruthBidStep, +} from './fixture-truth-bid'; +import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards'; + +export type TruthAllianceStep = TruthBidStep; +export type TruthAllianceTransition = { before: Game; steps: TruthAllianceStep[]; after: Game }; +export const actTruthAlliance = actTruthBid; +export const truthAllianceClean = truthBidClean; +export const truthAllianceInventory = truthBidInventory; +export const truthAlliancePlayer = truthBidPlayer; +export function truthAllianceNexus(game: Game) { + return game.phase === 1 && game.nexus && !game.spiceWindow && !game.spiceResolution && truthAllianceClean(game); +} +export function nextTruthAlliance(game: Game): TruthAllianceStep | null { + if (game.truthtrance) return null; + for (const player of game.players) { + const guide = viewGame(game, player.id).alliancePromiseGuide; + if (guide) return { actor: player.id, action: guide.action }; + } + return nextTruthBid(game); +} +export function advanceTruthAlliance(game: Game, until: (state: Game) => boolean, actions: TruthAllianceStep[] = []): Game { + for (let count = 0; count < 1500; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The genuine alliance source ended before its owned control'); + const next = nextTruthAlliance(game); assert.ok(next, 'The original Truthtrance question, answer or saved card belongs to its caller'); + game = actTruthAlliance(game, next.actor, next.action, actions); + } + throw Error('The genuine alliance source did not reach its finite boundary'); +} +export function settleTruthAlliance(game: Game, actions: TruthAllianceStep[] = []): Game { + return advanceTruthAlliance(game, truthAllianceClean, actions); +} +export function closeTruthAllianceNexus(game: Game, actions: TruthAllianceStep[] = []): Game { + assert.ok(truthAllianceNexus(game)); const turn = game.turn; + return advanceTruthAlliance(game, state => state.turn === turn && !state.nexus && truthAllianceClean(state), actions); +} +export function finishTruthAlliance(game: Game, actions: TruthAllianceStep[] = []): Game { + const turn = game.turn; + return advanceTruthAlliance(game, state => state.turn === turn && state.phase === 2 && truthAllianceClean(state), actions); +} + +/** Original public H/A/E base33, two actual Harkonnen Truthtrances and no + * optional Nexus Cards. Finish the original first turn and real later Storm; + * select only an unopened original worm plus ordinary replacements, then open + * the real ordinary Nexus. Consent is a native partner offer and reciprocal + * acceptance, never an installed ally/offer or assumed foreign decision. */ +export function createTruthAllianceFixture(options: { + advanced?: boolean; consent?: 'none' | 'offered' | 'allied'; partner?: 'atreides' | 'harkonnen'; +} = {}) { + const advanced = options.advanced ?? false, consent = options.consent ?? 'offered'; + const base = createTruthBidFixture({ advanced, window: 'waiting' }); + const partner = options.partner ?? base.opponent; + let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions); + const act = (actor: string, action: Action) => { game = actTruthAlliance(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceTruthAlliance(game, until, actions); }; + const transition = (run: () => void): TruthAllianceTransition => { + const before = structuredClone(game), from = actions.length; run(); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + }; + const nextStorm = transition(() => { + reach(state => state.turn === 2 && beforeIxSpiceDraw(state)); + }); + assert.ok(game.players.every(player => player.ally === null)); + const nexusSpiceOrder = orderIxSpiceCards(game, ['worm', 'land', ...(advanced ? ['land' as const] : [])]); + const beforeWorm = structuredClone(game); + const encounter = transition(() => { reach(truthAllianceNexus); }); + assert.equal(game.turn, 2); assert.equal(game.phase, 1); assert.equal(game.nexus, true); + assert.ok(game.spiceDiscard.flat().some(card => 'worm' in card && !card.suppressed)); + assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const beforeConsent = structuredClone(game); + let offer: TruthAllianceTransition | null = null, acceptance: TruthAllianceTransition | null = null; + if (consent !== 'none') { + offer = transition(() => { act(partner, { type: 'alliance', target: base.target }); }); + assert.equal(game.allianceOffers[partner], base.target); assert.equal(truthAlliancePlayer(game, base.target).ally, null); + } + if (consent === 'allied') { + acceptance = transition(() => { act(base.target, { type: 'alliance', target: partner }); }); + assert.equal(truthAlliancePlayer(game, base.target).ally, partner); + assert.equal(truthAlliancePlayer(game, partner).ally, base.target); + } + assert.deepEqual(truthAllianceInventory(game), base.originalCards); + assert.equal(truthAlliancePlayer(game, base.target).spice, 10); + assert.equal(viewGame(game, base.target).alliancePromiseNexus?.turn, game.turn); + const question = { kind: 'alliance' as const, target: base.target, turn: game.turn, partner }; + return { ...base, game, sourceGame: structuredClone(game), base, advanced, consent, partner, + nextStorm, beforeWorm, nexusSpiceOrder, encounter, beforeConsent, offer, acceptance, + question, questionAction: { type: 'truthAsk', question } as Action, + allianceAction: { type: 'alliance', target: partner } as Action, + breakAction: { type: 'alliance', target: null } as Action, + actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, + staging: [] as string[], sourceDisclosure: 'Genuine public original H/A/E base33 and native first-turn/later-Storm controls. Only inherited unopened original deal/lot/Spice positions, the later original worm/replacement prefix and scalar lotteries are selected. The actual ordinary worm opens this Nexus; native reciprocal offers earn any existing alliance. No clock, hand, wallet, offer, ally or promise is installed, and no foreign consent is invented.' }; +} +export type TruthAllianceFixture = ReturnType; +export function declareTruthAlliance(f: TruthAllianceFixture, state = f.game, options: { copies?: 1 | 2 } = {}) { + let game = state; const before = structuredClone(game), actions: TruthAllianceStep[] = []; + const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) }; + game = actTruthAlliance(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 = actTruthAlliance(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return { game, before, declaration, actions, actionCount: actions.length }; +} +export function askTruthAlliance(game: Game, asker: string, target: string, partner: string, + actions: TruthAllianceStep[] = []): Game { + return actTruthAlliance(game, asker, { type: 'truthAsk', question: { kind: 'alliance', target, turn: game.turn, partner } }, actions); +} +export function answerTruthAlliance(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', + actions: TruthAllianceStep[] = []): Game { + return actTruthAlliance(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthAlliance(f: TruthAllianceFixture, answer: 'yes' | 'no' = 'yes', partner = f.partner, state = f.game) { + const declared = declareTruthAlliance(f, state), actions = [...declared.actions]; + const asked = askTruthAlliance(declared.game, f.asker, f.target, partner, actions); + const game = answerTruthAlliance(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} diff --git a/tests/truth-alliance-controls.test.tsx b/tests/truth-alliance-controls.test.tsx new file mode 100644 index 00000000..15847ac8 --- /dev/null +++ b/tests/truth-alliance-controls.test.tsx @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { AllianceClaimFields, allianceClaimInputError } from '../components/alliance-claim-fields'; +import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import type { AllianceClaim } from '../game/alliance-promises'; +import { createTechTokens } from '../game/tech-tokens'; +import { + createTruthAllianceFixture, bindTruthAlliance, actTruthAlliance, settleTruthAlliance, + closeTruthAllianceNexus, truthAllianceInventory, +} from './fixture-truth-alliance'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { Truthtrance } = await import('../components/truthtrance'); +aliases.deregister(); + +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function partner(props: Parameters[0], value: string) { + const input = elements(AllianceClaimFields(props)).find(element => element.props.id === 'truth-alliance-partner'); + assert.ok(input); + (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); +} +function guide(props: Parameters[0]) { + const button = AlliancePromiseGuideButton(props); assert.ok(button); return button; +} +const press = (button: ReturnType) => button.props.onClick(); +const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(AlliancePromises, { game, busy, send() {} })); + +/** Controlled personalized Nexus projections exercise UI guards only; the + * native source below earns its worm, consent, Truthtrance and final close. */ +function projected(kind: 'accept' | 'break' | 'ready' = 'accept'): GameView { + const source = createGame('TRUTHALLIANCECONTROLS', newPlayer('p', 'Respondent', 'emperor')); + joinGame(source, newPlayer('a', 'Asker', 'harkonnen')); + joinGame(source, newPlayer('r', 'Partner', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = 1; game.nexus = true; game.spiceWindow = null; + game.active = 'a'; game.ready = []; game.alliancePromiseNexus = { turn: 2 }; + game.alliancePromises = [{ event: 'controlled-alliance-event', player: 'p', asker: 'a', turn: 2, + partner: 'r', answer: kind !== 'break' }]; + game.allianceOffers = kind === 'accept' ? { r: 'p' } : {}; + if (kind !== 'accept') { + game.players.find(player => player.id === 'p')!.ally = 'r'; + game.players.find(player => player.id === 'r')!.ally = 'p'; + } + game.alliancePromiseGuide = { action: { type: kind === 'ready' ? 'ready' : 'alliance', + ...(kind === 'ready' ? {} : { target: kind === 'accept' ? 'r' : null }), alliancePromiseEvent: 'controlled-alliance-event' } }; + return game; +} + +void test('alliance claim fields fix this Nexus turn and accept only a current named other partner', () => { + const value: AllianceClaim = { turn: 2, partner: 'r' }, changes: AllianceClaim[] = []; + const partners = [{ id: 'r', name: 'Partner' }, { id: 'a', name: 'Asker' }]; + const props = { value, partners, onChange(next: AllianceClaim) { changes.push(next); } }; + const html = renderToStaticMarkup(createElement(AllianceClaimFields, props)); + assert.match(html, /Turn 2 · Current Nexus/); assert.match(html, /Named alliance partner/); + assert.match(html, /actual membership when this Nexus closes/); assert.match(html, /Offers alone are not alliances/); + assert.match(html, /cannot promise the other player’s consent/); + assert.equal(elements(AllianceClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1); + partner(props, 'a'); assert.deepEqual(changes, [{ turn: 2, partner: 'a' }]); + for (const invalid of ['missing', '', ' r ']) partner(props, invalid); + partner({ ...props, disabled: true }, 'a'); partner({ ...props, partners: [] }, 'a'); + partner({ ...props, value: { ...value, turn: 0 } }, 'a'); assert.equal(changes.length, 1); + assert.ok(allianceClaimInputError(value, [])); + assert.match(renderToStaticMarkup(createElement(AllianceClaimFields, { ...props, partners: [] })), /role="alert"/); +}); + +void test('own alliance promises describe final membership and remain live after formation until the actual Nexus close', () => { + const game = projected('ready'); + game.alliancePromises.push( + { event: 'no-event', player: 'p', asker: 'a', turn: 2, partner: 'a', answer: false }, + { event: 'fulfilled-event', player: 'p', asker: 'a', turn: 1, partner: 'r', answer: true, fulfilled: true }, + { event: 'released-event', player: 'p', asker: 'a', turn: 1, partner: 'a', answer: true, released: true }, + { event: 'foreign-event', player: 'a', asker: 'p', turn: 2, partner: 'r', answer: true }, + ); + const before = structuredClone(game); + for (let render = 0; render < 2; render++) { + const html = markup(game); + assert.match(html, /Your alliance promises/); assert.match(html, /be allied with Partner when this Nexus ends/); + assert.match(html, /Yes remains live after forming an alliance and settles only at the close/); + assert.match(html, /a temporary alliance can still be ended before the close/); + assert.match(html, /Offers alone are not alliances/); assert.match(html, /Neither answer promises another player’s consent/); + assert.match(html, /Honor any earlier promises first/); assert.match(html, /Fulfilled at the Nexus close/); + assert.match(html, /href="\/rules#truthtrance-alliance"/); + assert.doesNotMatch(html, /foreign-event|controlled-alliance-event/); + } + assert.deepEqual(game, before); + assert.equal(AlliancePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null); +}); + +void test('actual guide callbacks forward reciprocal acceptance, explicit-null break and Ready out of turn', () => { + for (const kind of ['accept', 'break', 'ready'] as const) { + const game = projected(kind), 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.alliancePromiseGuide!.action]); assert.equal(sent[0], game.alliancePromiseGuide!.action); + assert.equal(game.active, 'a'); assert.notEqual(game.active, game.me); + if (kind === 'break') assert.equal(sent[0].target, null); + press(guide({ ...props, busy: true })); assert.equal(sent.length, 1); + } +}); + +void test('stale guides cannot cross Nexus closure, readiness, membership, consent, current event or quiet windows', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 2; }, + game => { game.status = 'setup'; }, game => { game.nexus = false; }, game => { game.ready = ['p']; }, + game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; }, + game => { game.alliancePromiseNexus = null; }, game => { game.alliancePromiseNexus!.turn = 1; }, + game => { game.alliancePromiseGuide = { action: { ...game.alliancePromiseGuide!.action } }; }, + game => { game.alliancePromiseGuide = null; }, game => { game.alliancePromiseGuide!.action.target = null; }, + game => { game.alliancePromises[0].event = 'expired'; }, game => { game.alliancePromises[0].player = 'a'; }, + game => { game.alliancePromises[0].fulfilled = true; }, game => { game.alliancePromises[0].released = true; }, + game => { game.alliancePromises = []; }, game => { game.allianceOffers = {}; }, + game => { game.players = game.players.filter(player => player.id !== 'r'); }, + 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(), 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 ready = projected('ready'); + ready.players.find(player => player.id === 'p')!.ally = null; ready.players.find(player => player.id === 'r')!.ally = null; + const sent: Action[] = []; press(guide({ game: ready, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0); + const noReady = projected('break'); noReady.alliancePromiseGuide!.action = { type: 'ready', alliancePromiseEvent: 'controlled-alliance-event' }; + press(guide({ game: noReady, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0); + const noConsent = projected(); noConsent.allianceOffers = {}; + press(guide({ game: noConsent, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0); + for (const target of ['p', 'missing']) { + const game = projected(); game.alliancePromiseGuide!.action.target = target; + press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0); + } + const props = { game: projected(), busy: false, send() { assert.fail('A now-busy alliance guide cannot dispatch'); } }; + const button = guide(props); props.busy = true; press(button); +}); + +void test('alliance guidance uses public membership and offers without inspecting private sources, supplies or card piles', () => { + const game = projected(), sent: Action[] = []; + for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'reserves', 'tanks']) + Object.defineProperty(seat, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + for (const key of ['spiceDeck', 'deck', 'discard']) Object.defineProperty(game, key, { + get() { throw Error(`No private ${key} inspection`); }, configurable: true, + }); + Object.defineProperty(game.alliancePromises[0], 'source', { get() { throw Error('No private Nexus source inspection'); } }); + assert.match(markup(game), /Accept Partner’s alliance invitation/); + press(guide({ game, busy: false, send(action) { sent.push(action); } })); + assert.deepEqual(sent, [game.alliancePromiseGuide!.action]); +}); + +void test('genuine Basic and Advanced guides accept actual consent and settle Yes only at the original Nexus close', () => { + for (const advanced of [false, true]) { + const f = createTruthAllianceFixture({ advanced, consent: 'offered' }), bound = bindTruthAlliance(f, 'yes', f.partner); + const respondent = viewGame(bound.asked, f.target); + assert.ok(respondent.truthAllianceAnswers?.includes('yes')); + assert.equal(viewGame(bound.asked, f.asker).truthAllianceAnswers, null); + const answers = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} })); + assert.match(answers, /Answer Yes/); assert.match(answers, /Answer No/); + let game = bound.game; + const dispatch = (type: string) => { + const view = viewGame(game, f.target), sent: Action[] = [], next = view.alliancePromiseGuide; assert.ok(next); + assert.equal(next.action.type, type); + press(guide({ game: view, busy: false, send(action) { sent.push(action); } })); + assert.deepEqual(sent, [next.action]); game = settleTruthAlliance(actTruthAlliance(game, f.target, sent[0])); + }; + assert.equal(game.allianceOffers[f.partner], f.target); + assert.equal(game.players.find(player => player.id === f.target)!.ally, null, 'A real offer alone has not formed the alliance.'); + dispatch('alliance'); + assert.equal(game.players.find(player => player.id === f.target)!.ally, f.partner); + assert.notEqual(game.alliancePromises!.find(promise => promise.player === f.target)!.fulfilled, true); + assert.match(markup(viewGame(game, f.target)), /This answer stays binding until this Nexus closes/); + dispatch('ready'); game = closeTruthAllianceNexus(game); + assert.equal(game.nexus, false); + assert.equal(game.alliancePromises!.find(promise => promise.player === f.target)!.fulfilled, true); + assert.deepEqual(truthAllianceInventory(game), f.originalCards); + assert.match(markup(viewGame(game, f.target)), /Fulfilled at the Nexus close/); + assert.equal(AlliancePromises({ game: viewGame(game, f.asker), busy: false, send() {} }), null); + } +}); diff --git a/tests/truth-alliance-interactions.test.ts b/tests/truth-alliance-interactions.test.ts new file mode 100644 index 00000000..3588f4db --- /dev/null +++ b/tests/truth-alliance-interactions.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { + createTruthAllianceFixture as fixture, bindTruthAlliance as bind, + declareTruthAlliance as declare, askTruthAlliance as ask, answerTruthAlliance as answer, + actTruthAlliance as act, closeTruthAllianceNexus as close, truthAlliancePlayer as player, + truthAllianceInventory as inventory, +} from './fixture-truth-alliance'; + +for (const answer of ['yes', 'no'] as const) void test(`controlled prior foreign shipment impossibility settles before a still-owned Alliance ${answer}`, () => { + const f = fixture(), bound = bind(f, answer); + const controlled = structuredClone(bound.game); + // Only this prior debt is controlled: it represents the already-lost funded + // route after a foreign event. Nexus, cards, invitation and answer are earned. + controlled.shipmentPromises = [{ turn: controlled.turn, player: f.target, asker: f.asker, + claim: { territory: 'carthag', minimum: 20 }, answer: true }]; + const game = act(controlled, f.partner, { type: 'bribe', target: f.asker, amount: 1 }); + assert.equal(game.shipmentPromises![0].released, true); + const promise = game.alliancePromises![0]; + assert.ok(!promise.fulfilled && !promise.released); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, answer === 'yes' ? 'alliance' : 'ready'); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truth cards preserve an owed native bribe before accepting its named Nexus partner', () => { + 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.partner, minimum: 3, + } }); + assert.ok(viewGame(game, f.target).truthBribeAnswers?.includes('yes')); + game = answer(game, f.target, 'yes'); + game = ask(game, f.asker, f.target, f.partner); + assert.ok(viewGame(game, f.target).truthAllianceAnswers?.includes('yes')); + game = answer(game, f.target, 'yes'); + assert.equal(viewGame(game, f.target).alliancePromiseGuide, null); + const payment = viewGame(game, f.target).bribePromiseGuide; assert.ok(payment); + const before = player(game, f.partner).spice; + game = act(game, f.target, payment.action); + assert.equal(player(game, f.target).spice, 7); + assert.equal(player(game, f.partner).spice, before); + assert.equal(player(game, f.partner).bribes, 3); + assert.ok(game.bribePromises![0].fulfilled); + const acceptance = viewGame(game, f.target).alliancePromiseGuide; assert.ok(acceptance); + game = act(game, f.target, acceptance.action); + assert.equal(player(game, f.target).ally, f.partner); + assert.ok(!game.alliancePromises![0].fulfilled); + game = close(game); + assert.ok(game.alliancePromises![0].fulfilled); + assert.equal(player(game, f.partner).bribes, 3); + assert.deepEqual(inventory(game), f.originalCards); +}); diff --git a/tests/truth-alliance-runtime.test.ts b/tests/truth-alliance-runtime.test.ts new file mode 100644 index 00000000..f8ea83c8 --- /dev/null +++ b/tests/truth-alliance-runtime.test.ts @@ -0,0 +1,166 @@ +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 { + actTruthAlliance as act, answerTruthAlliance as answer, askTruthAlliance as ask, bindTruthAlliance as bind, + closeTruthAllianceNexus as close, createTruthAllianceFixture as fixture, declareTruthAlliance as declare, + finishTruthAlliance as finish, truthAllianceInventory as inventory, truthAllianceNexus as nexus, truthAlliancePlayer as player, +} from './fixture-truth-alliance'; + +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.alliancePromises!.find(promise => promise.player === target)!; + +for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} current Nexus Yes accepts real consent and fulfills only at actual alliance closure`, () => { + const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.equal(f.game.turn, 2); assert.ok(nexus(f.game)); assert.equal(f.originalCards.length, 33); + assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length); + assert.equal(f.offer!.steps[0].actor, f.partner); assert.equal(f.game.allianceOffers[f.partner], f.target); + assert.equal(player(f.game, f.target).ally, null); + const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before); + assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1); + assert.ok(!promise(bound.game, f.target).fulfilled); rejected(bound.game, f.target, { type: 'ready' }); + const guide = viewGame(bound.game, f.target).alliancePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.partner); + let game = act(bound.game, f.target, guide.action); + assert.equal(player(game, f.target).ally, f.partner); assert.equal(player(game, f.partner).ally, f.target); + assert.ok(!promise(game, f.target).fulfilled, 'Actual acceptance is still provisional until this Nexus closes'); + game = close(game); + assert.equal(game.nexus, false); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(player(game, f.target).ally, f.partner); + assert.equal(viewGame(game, f.target).alliancePromiseNexus, null); + game = finish(game); assert.equal(game.phase, 2); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native No for an existing reciprocal alliance breaks it before owned readiness and waits for closure', () => { + const f = fixture({ consent: 'allied' }); let game = bind(f, 'no').game; + assert.equal(player(game, f.target).ally, f.partner); rejected(game, f.target, { type: 'ready' }); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, null); + game = act(game, f.target, guide.action); + assert.equal(player(game, f.target).ally, null); assert.equal(player(game, f.partner).ally, null); + assert.ok(!promise(game, f.target).fulfilled); + game = close(game); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native final No permits an interim newly formed alliance but requires its own break before Ready', () => { + const f = fixture({ consent: 'none' }); let game = bind(f, 'no').game; + game = act(game, f.partner, { type: 'alliance', target: f.target }); + game = act(game, f.target, { type: 'alliance', target: f.partner }); + assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).fulfilled); + rejected(game, f.target, { type: 'ready' }); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action); + assert.equal(player(game, f.target).ally, null); game = close(game); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native absent foreign consent offers No or unknown without inventing an alliance and preserves the saved card', () => { + const f = fixture({ consent: 'none' }), declared = declare(f); + let game = ask(declared.game, f.asker, f.target, f.partner); + assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['no', 'unknown']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = answer(game, f.target, 'unknown'); assert.equal(game.truthtrance!.stage, 'unknown'); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 0); + game = act(game, f.asker, { type: 'truthSave' }); + assert.equal(game.truthtrance, null); assert.equal(player(game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 2); + assert.equal(game.alliancePromises?.length ?? 0, 0); + game = act(game, f.partner, { type: 'alliance', target: f.target }); + game = bind(f, 'yes', f.partner, game).game; game = close(game); + assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native binding reopens the respondent own readiness and cannot finish with unmatched membership', () => { + const f = fixture(); let game = act(f.game, f.target, { type: 'ready' }); + assert.ok(game.ready.includes(f.target)); game = bind(f, 'yes', f.partner, game).game; + assert.ok(!game.ready.includes(f.target)); rejected(game, f.target, { type: 'ready' }); + game = close(game); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two physical Truth copies constrain the same final partner without settling at proposal or acceptance', () => { + const f = fixture(), declared = declare(f, f.game, { copies: 2 }); + let game = ask(declared.game, f.asker, f.target, f.partner); game = answer(game, f.target, 'yes'); + game = ask(game, f.asker, f.target, f.partner); + assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['yes']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes'); + assert.equal(game.alliancePromises!.length, 2); assert.ok(game.alliancePromises!.every(promise => !promise.fulfilled)); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action); + assert.ok(game.alliancePromises!.every(promise => !promise.fulfilled)); game = close(game); + assert.ok(game.alliancePromises!.every(promise => promise.fulfilled)); + 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 actual foreign offers cannot answer Yes to two different final partners in one Nexus', () => { + const f = fixture(); let game = act(f.game, f.asker, { type: 'alliance', target: f.target }); + assert.equal(game.allianceOffers[f.partner], f.target); assert.equal(game.allianceOffers[f.asker], f.target); + game = declare(f, game, { copies: 2 }).game; + game = ask(game, f.asker, f.target, f.partner); game = answer(game, f.target, 'yes'); + game = ask(game, f.asker, f.target, f.asker); + assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no'); + game = close(game); assert.equal(player(game, f.target).ally, f.partner); + assert.ok(game.alliancePromises!.every(promise => promise.fulfilled)); assert.deepEqual(inventory(game), f.originalCards); +}); + +for (const consent of ['offered', 'allied'] as const) void test(`native opposing ${consent === 'offered' ? 'offer withdrawal' : 'alliance break'} releases a Yes with no owned acceptance left`, () => { + const f = fixture({ consent }); let game = bind(f).game; + game = act(game, f.partner, { type: 'alliance', target: null }); + assert.equal(player(game, f.target).ally, null); assert.equal(game.allianceOffers[f.partner], undefined); + assert.equal(promise(game, f.target).released, true); assert.ok(!promise(game, f.target).fulfilled); + game = close(game); assert.equal(player(game, f.target).ally, null); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native respondent cannot erase the only final Yes witness by its own actual alliance break', () => { + const f = fixture({ consent: 'allied' }), game = bind(f).game; + rejected(game, f.target, { type: 'alliance', target: null }); + assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).released); + const closed = close(game); assert.equal(promise(closed, f.target).fulfilled, true); +}); + +void test('native current Nexus question ownership and exact guide event reject foreign and stale callbacks', () => { + const f = fixture(), 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, partner: f.target } }); + let game = ask(declared.game, f.asker, f.target, f.partner); + assert.ok(viewGame(game, f.target).truthAllianceAnswers!.includes('yes')); + assert.equal(viewGame(game, f.asker).truthAllianceAnswers, null); + rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes'); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.ok(guide.action.alliancePromiseEvent); + assert.ok(viewGame(game, f.target).alliancePromises.every(promise => !Object.hasOwn(promise, 'source'))); + rejected(game, f.asker, guide.action); + rejected(game, f.target, { ...guide.action, alliancePromiseEvent: 'stale-owned-event' }); + game = act(game, f.partner, { type: 'alliance', target: null }); + assert.equal(promise(game, f.target).released, true); + game = act(game, f.partner, { type: 'alliance', target: f.target }); + rejected(game, f.target, guide.action); + game = act(game, f.target, { type: 'alliance', target: f.partner }); + assert.equal(player(game, f.target).ally, f.partner); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('all four minimal policies answer and accept a real current foreign Nexus offer legally', () => { + const f = fixture(), declared = declare(f), asked = ask(declared.game, f.asker, f.target, f.partner); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(asked, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'truthAnswer'); assert.ok(action, difficulty); + assert.ok(view.truthAllianceAnswers!.some(answer => answer === action.answer)); + const answered = act(structuredClone(asked), f.target, action); + assert.equal(answered.discard.filter(card => card.id === f.truth).length, action.answer === 'unknown' ? 0 : 1); + assert.deepEqual(inventory(answered), f.originalCards); + } + const bound = bind(f).game; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(bound, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'alliance' && action.target === f.partner); assert.ok(action, difficulty); + const accepted = act(structuredClone(bound), f.target, action); + assert.equal(player(accepted, f.target).ally, f.partner); assert.ok(!promise(accepted, f.target).fulfilled); + assert.deepEqual(inventory(close(accepted)), f.originalCards); + } +});