From 561152d58ecf42f0461174cf358fcab00af0d51f Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:10:12 +1100 Subject: [PATCH] Compose Guild Cunning with physical Ornithopter movement Provisionally let either physical card mode replace an earned unused Hajr movement after the second shipment. Keep active group rights separate from actual completed credits and original movement counts. Add v3 original-count receipts while preserving old formats; abandon partial flights through a bound discard suffix before Cunning resumes. Connect persistent controls and minimal legal bot candidates. Validation: frozen13-file125/125, types/lint/build and eight original-action/rendered-table continuations. Public ready/start, cache1/Hajr1 purchases, real worm/alliance/Guild Nexus draw and paid first2/second1 shipments cover both modes, earlier flights and Basic early endings. Eight minimal policy proposals are legal. No frozen repair; bounded continuation review found no concrete defect. New Semuta grammar lacks direct combined claim/capture proof. Wider combinations, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/reset/restart/deployment operation. --- README.md | 3 + components/game-table.tsx | 6 +- components/ornithopter-movement.tsx | 10 + docs/CURRENT_STATUS.md | 26 +++ docs/IMPLEMENTATION_STATUS.md | 24 ++ docs/NEXUS_GUILD_CUNNING_RUNTIME.md | 67 +++++- docs/NEXUS_GUILD_RULES.md | 4 +- docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md | 26 +++ docs/RULE_DECISIONS.md | 11 + game/bots.ts | 2 +- game/engine.ts | 71 ++++-- game/nexus-guild-cunning.ts | 75 ++++-- game/reference.ts | 17 +- tests/fixture-guild-cunning-ornithopter.ts | 215 ++++++++++++++++++ .../guild-cunning-ornithopter-engine.test.ts | 181 +++++++++++++++ tests/nexus-guild-cunning-ornithopter.test.ts | 203 +++++++++++++++++ 16 files changed, 887 insertions(+), 54 deletions(-) create mode 100644 tests/fixture-guild-cunning-ornithopter.ts create mode 100644 tests/guild-cunning-ornithopter-engine.test.ts create mode 100644 tests/nexus-guild-cunning-ornithopter.test.ts diff --git a/README.md b/README.md index 5592441d..1d048953 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ uses one unused movement entitlement and retains Hajr's separate move. The single physical Hajr can be played again after legitimate [paid Box recovery](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026). Advanced still requires a fresh post-movement opportunity. +After Guild Cunning, either physical Ornithopter mode may provisionally replace +an earned unused Hajr movement. Cunning itself adds no ordinary move; abandoned +card groups grant no extra credit. See the [combination contract](docs/NEXUS_GUILD_CUNNING_RUNTIME.md#physical-ornithopter-composition--18-october-2026). Advanced advisors may use both physical Ornithopter modes. Advisors do not grant city access; normal-range groups use current fighter occupancy. See the [advisor source cutover](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#advanced-advisor-card-movement--18-october-2026). diff --git a/components/game-table.tsx b/components/game-table.tsx index 19b805fb..4bb8b0ad 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -5001,7 +5001,7 @@ export function GameTable({ {guildCunning?.stage === 'secondShipment' ? 'Choose or decline the second shipment before any Hajr movement.' : guildCunning?.stage === 'extraMove' - ? `${guildCunning.movesLeft} unused Hajr movement remaining. No ordinary movement is granted.` + ? `${guildCunning.movesLeft} movement remaining from unused Hajr or the active card. No ordinary movement is granted.` : (me.moved ?? 0) < (me.movesAllowed ?? 1) ? `${(me.movesAllowed ?? 1) - (me.moved ?? 0)} movement remaining.` : 'Movement complete. Finish your turn when your shipment is also settled.'} @@ -5066,7 +5066,7 @@ export function GameTable({ {g.nexusGuildCunning?.offer && (
-

Guild Cunning ends your ordinary shipment and movement, then offers one second shipment at normal Guild prices. Only an unused Hajr move can follow it. The physical Ornithopter card combination remains unavailable.

+

Guild Cunning ends your ordinary shipment and movement, then offers one second shipment at normal Guild prices. Only an unused Hajr move can follow it. Provisional card combination: Ornithopter can replace that unused Hajr movement; declaring Cunning abandons any unfinished current card group.

{g.nexusGuildCunning.offer.blocked &&

{g.nexusGuildCunning.offer.blocked}

} {actionButton('Finish ordinary turn and declare Guild Cunning', guildCunningAction ?? {type: 'endMovement'}, !guildCunningAction)} @@ -5075,7 +5075,7 @@ export function GameTable({ {guildCunning &&

{guildCunning.stage === 'secondShipment' ? 'Guild Cunning: choose your second shipment, or skip it and retain an unused Hajr move. This does not reopen ordinary movement.' : guildCunning.stage === 'extraMove' - ? 'The second shipment is settled. Use an unused Hajr move or finish your turn.' + ? 'The second shipment is settled. Use an unused Hajr move, replace it with Ornithopter, finish an active card group, or finish your turn.' : 'Guild Cunning awaits its cancellation response.'}

} {guildCunning?.stage === 'secondShipment' && actionButton('Skip second shipment', nexusGuildSkipShipmentAction(g, guildCunning.event) ?? {type: 'endMovement'}, diff --git a/components/ornithopter-movement.tsx b/components/ornithopter-movement.tsx index 7c7ab02f..1982497d 100644 --- a/components/ornithopter-movement.tsx +++ b/components/ornithopter-movement.tsx @@ -31,6 +31,7 @@ export function OrnithopterMovement({ const me = game.players.find((p) => p.id === game.me); if (!card || !me) return null; const advisorGuidance = game.advanced && me.faction === 'beneGesserit'; + const guildGuidance = !!game.nexusGuildCunning?.active; const active = card.active; const mode = active?.mode ?? @@ -182,6 +183,14 @@ export function OrnithopterMovement({

)} + {guildGuidance && ( +

+ Provisional Guild Cunning combination: Ornithopter replaces one unused + Hajr movement after the second shipment. Two-group mode keeps its second + different group available until you finish it or end your turn. Ending + early grants no movement for the abandoned group. +

+ )} {advisorGuidance && (

Advanced Bene Gesserit advisors can use both card modes. Advisors in @@ -207,6 +216,7 @@ export function OrnithopterMovement({ className="min-h-11 whitespace-normal motion-reduce:transition-none" disabled={busy || !!reason} aria-describedby={[ + ...(guildGuidance ? [`${id}-guild-guidance`] : []), ...(advisorGuidance ? [`${id}-advisor-guidance`] : []), ...(reason ? [`${id}-reason`] : []), ].join(' ') || undefined} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 4ec1910b..f46f980d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,32 @@ open mode gates. ## Current checkpoint and work +**Guild Cunning / physical Ornithopter — 18 October. Prototyped / Partial.** +Either physical card mode may provisionally replace an earned unused Hajr +allowance after Cunning's second shipment. No ordinary move is added. Original +physical counts, prior completed flight credit and the active second-group right +remain distinct. Ending a partial flight grants no abandoned-group credit; +declaring Cunning disposes it before the original Nexus response resumes. +New v3 receipts/final-count snapshots retain exact old receipt formats. + +Two disjoint lanes, bounded continuation review and owner integration pass +**13 files125/125**, types/lint/build and eight finite original-action/rendered- +table continuations. Two public host/setup programmes use81 Basic/93 Advanced +source actions: cache1/Hajr1 purchases, real worm/alliance/Guild Nexus draw and +paid first shipment2. Each second shipment pays1; continuations use6–8 actions. +Both modes and earlier completed flights work in Basic/Advanced; Basic early +endings preserve no abandoned credit. Eight minimal policy proposals are legal. +An additional original-action regression rejects both an unearned and already +spent Hajr grant without consuming the held physical card. + +All frozen checks passed without repair. Actual GameTable views and isolated +accepted movement drafts render; no select/submit browser click or visual/ +browser/deployed acceptance. Semuta's new suffix grammar is connected and prior +disposal regressions pass, but direct combined claim/capture proof stays open. +Wider combinations, full modes and final assurance remain due. No saved-room/ +database/reset/restart/deployment operation. +[Source, composition and exact proof](NEXUS_GUILD_CUNNING_RUNTIME.md#physical-ornithopter-composition--18-october-2026). + **Advanced advisor / physical Ornithopter — 18 October. Prototyped / Partial.** The supplied source excludes advisors from granting city access, rather than using this physical card. Both modes now use native stance, arrival/locks and diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 12dd0964..cdde846f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,29 @@ # Dune implementation status +## 18 October 2026 — Guild Cunning with the physical Ornithopter + +Provisional unused-entitlement composition now permits either card mode to +replace an earned unused Hajr grant after the second shipment. Cunning adds no +ordinary move. Prior completed credits and actual physical group counts remain; +an active two-group flight retains only its original second group. New v3 +receipts/final snapshots preserve old formats. Declaring Cunning during a partial +flight discards it before resuming the original response; ending afterward +completes the turn. Abandoned groups grant no extra credit. + +Two disjoint lanes, bounded continuation review and owner integration pass +13-file125/125, types/lint/build and eight finite original-action/rendered-table +continuations. Public original setup, cache1/Hajr1 purchases, real worm/alliance/ +Guild draw and paid first shipment2 use81 Basic/93 Advanced source actions; +second shipment1 and6–8 continuation actions cover both modes, earlier completed +flights and Basic early endings. Eight minimal policy proposals are legal. +Original-action rejection cases preserve the held card without an unused Hajr. +No frozen check needed repair. [Canonical contract and exact proof](NEXUS_GUILD_CUNNING_RUNTIME.md#physical-ornithopter-composition--18-october-2026). +Rendering does not click controls or establish visual/browser/deployed acceptance. +Semuta grammar is connected; direct combined claim/capture proof, wider +combinations, full modes and final assurance remain open. No saved-room/database/ +reset/restart/deployment operation. + + ## 18 October 2026 — Advanced advisor physical Ornithopter Both physical card modes now accept advisors under the supplied city-access diff --git a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md index 79f28344..ed081bc2 100644 --- a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md +++ b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md @@ -2,6 +2,70 @@ This checkpoint follows the [Guild source contract](NEXUS_GUILD_RULES.md): native Guild can spend Cunning when finishing its ordinary Shipment and Movement turn to obtain one second shipment. It preserves one combined turn and the actual history of the first shipment and movement. It does not implement Guild Secret Ally, Betrayal or complete Nexus play. +## Physical Ornithopter composition — 18 October 2026 + +The [printed Guild face](NEXUS_GUILD_RULES.md#primary-evidence) grants only +one second shipment; Hajr remains necessary for later movement. Under the +standing recommended-composition policy, the physical Ornithopter may replace +one earned unused Hajr allowance after that shipment. This is an explicit +provisional interaction, not a new publisher FAQ or a free ordinary move. + +Range-three mode consumes that allowance once. Two-group mode retains both +original distinct groups even after the first consumes the underlying Hajr +allowance; only actually completing both groups earns the extra physical-move +credit. Original movement counters still count every group. Earlier completed +flights retain their earned credit when Cunning is declared. A spent Hajr +cannot be reused merely by playing either card. + +Declaring Cunning during an unfinished ordinary flight abandons its remaining +optional group and discards the physical card before resuming the original +Cunning response. The new bounded `ornithopterDiscard` source `cunning` parks +that response rather than ending the turn. Ordinary early ending after Cunning +uses the existing end-turn suffix and completes the Cunning lifecycle. Neither +early ending earns the abandoned group's credit. Existing discard consumers, +including Semuta's supported-suffix grammar, retain the actual saved response. +This alone does not certify every capture/claim combination. + +New v3 receipts bind the original completed Ornithopter count as well as actual +Hajr plays. Optional signed lifecycle snapshots retain final earned counts after +phase resets. Exact v1/v2 receipt bytes and unstamped lifecycle signatures keep +their original meanings; an older active receipt can acquire a genuinely earned +new credit without rewriting its original history. The pure helper never invents +rights for an unfinished flight; the engine retains that flight's own ceiling. + +Controls keep the Hajr prerequisite and provisional interpretation visible; +all four minimal policies can propose legal first/second card groups. Strategic +AI refinement, wider combinations, full modes and final assurance remain open. +The frozen batch passes **13 files125/125**, types/lint/build and eight finite +original-action/rendered-table continuations. Two genuine public ready/start +programmes use81 Basic/93 Advanced source actions: cache Ornithopter1 and normal +Hajr1 purchases, turn2 worm and reciprocal Richese/Emperor alliance, actual +closing Guild Nexus draw from the unopened deck and paid reserve shipment2. +Each real second reserve shipment pays1; six to eight continuation actions cover +both modes, completed earlier flights and Basic early endings. Eight first/ +second-group policy proposals are legal. Another original-action regression +rejects the held card with no earned Hajr or with its grant already spent. +Nine new pure cases cover original credits, earned/completed counts, safe bounds, +strict v3 shapes and exact old receipt bytes/defaults. + +All frozen checks passed without repair. Actual GameTable views and isolated +controls with accepted drafts render; no select/submit browser click. A bounded +read-only continuation/count review found no concrete defect. Prior Semuta +Homeworld/disposal regressions pass, but the new Cunning suffix itself has native +response proof rather than direct combined Semuta claim/capture evidence. +No complete modes, wider combinations, strategic calibration, final assurance or +visual/browser/deployed acceptance is claimed. No saved-room/database/reset/ +restart/deployment operation. + +Private artifacts: `/home/quintine/.local/share/dune/checkpoints/20261018-guild-cunning-ornithopter/`. +`frozen-integrated/report.json` binds source base `7fe84ff` and tree +`80097576d1033dad38a08cf85814664bd604bc76d623780e51a1b82d024ed395`. +`runtime-smoke.json` and private rendered HTML record the actual controls and +original actions. Final checkpoint prose/reference evidence is a documented +post-proof overlay; `documentation-overlay.json`/`git-result.json` bind that +reviewed overlay and pushed checkpoint. Relevant source, controls, helpers and +tests remain identical to the frozen proof. + ## Classic core payment acknowledgement — 18 October 2026 Original classic base33 or selected independent36 Nexus, optionally all14, @@ -87,7 +151,8 @@ No ordinary movement follows the extra shipment. Every remaining movement must come from an actually earned unused Hajr allowance. A consumed grant cannot be spent twice; a legally recovered and replayed physical Hajr adds a new grant. The same native Guild player retains that continuation. Native city range still -applies normally; physical Ornithopter card composition remains unavailable. +applies normally. The physical-card composition above supersedes the earlier +unavailable boundary within its named scope. The [18 October replay checkpoint](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026) adds counted v2 declarations and current/final count snapshots, retaining exact diff --git a/docs/NEXUS_GUILD_RULES.md b/docs/NEXUS_GUILD_RULES.md index 866c2c35..80489411 100644 --- a/docs/NEXUS_GUILD_RULES.md +++ b/docs/NEXUS_GUILD_RULES.md @@ -57,7 +57,7 @@ Search results concerning tournament house rules and the separate 2021 movie gam **Movement remains bounded.** The second shipment does not grant an ordinary movement. Ending the first ordinary movement by declining it cannot leave an ordinary move available for newly shipped forces. The express exception is Hajr. If Hajr is played after the second shipment, retain the same active player until its optional legal extra move finishes or is declined. A previously played but unspent Hajr move can remain available under ordinary allowance composition; a Hajr move already consumed before the second shipment cannot grant a third move. Track the actual remaining allowance, not merely whether Hajr appears in a played-card list. A later move checks current ornithopter access normally. -The card does not establish how an independent Richese Ornithopter movement card combines with this exception or with earlier Hajr usage. Preserve the already documented [Hajr/Ornithopter boundary](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md) instead of assuming a third/fourth move. Native city ornithopter range is a different matter from that physical Treachery Card. +The card does not expressly establish how the separate physical Richese Ornithopter combines with this exception. The [18 October provisional composition](NEXUS_GUILD_CUNNING_RUNTIME.md#physical-ornithopter-composition--18-october-2026) permits it to replace one earned unused Hajr allowance, retaining actual completed-group counts and no abandoned-group credit. This supersedes the earlier software exclusion; it is not a dedicated publisher ruling. Native city range remains a different ability. **Separate native cancellations.** Cunning creates an enhanced native shipment allowance. Treating its declaration as a cancelable native enhancement is the common Cunning/general Karama composition. Cancellation consumes the attempted Nexus use but grants no second shipment and cannot undo the first shipment or completed movement. This is distinct from permitting a second shipment while canceling its discounted rate or its cross/return form. The base FAQ explicitly treats those native advantages separately; one generic cancellation must not silently choose all three outcomes. Existing rate/route cancellation limitations remain implementation work. Independent Karama shipment permission also has its own payment route; it must not silently authorize a second shipment after Cunning itself was canceled. @@ -89,7 +89,7 @@ Preserve distinct settlement adapters: ordinary reserve shipment, `guildShip`, n The pure [Cunning receipt module](../game/nexus-guild-cunning.ts) records the original combined-turn event, native Guild owner, turn, canonical faction roster, original shipment flag, original movement count and whether Hajr had been played. `createNexusGuildCunning` checks the unallied owner's active phase-five turn. `validateNexusGuildCunning` checks historical facts without reading current hands, resources, alliances or force custody. The engine owns the independently bound mutable lifecycle and second-shipment intent/receipt. Actual shipment custody and payment remain in their existing continuations. Do not clear a shipped flag as the sole source of the extra right, and do not use current hand membership as proof after spending the Nexus. Reconnect and uncertain responses must preserve one debit, one arrival and one consumed allowance. -`nexusGuildCunningMoves(receipt, currentHajrUsed, movesAfterSecond)` returns only the remaining Hajr extra allowance. Ending ordinary movement forgoes at least one ordinary slot, so original movement counts zero and one both permit at most one Hajr move; an original count of two permits none. A newly played Hajr supplies that same one extra slot, while losing a previously recorded Hajr or claiming more moves than that allowance rejects. This bounded helper excludes the separate physical Ornithopter-card composition; it does not reset or mutate the original movement counter. Its [pure tests](../tests/nexus-guild-cunning.test.ts) cover declined and used first actions, Hajr before and after, spent allowances, native ownership, exact signatures, JSON restoration and private-field getter traps. +`nexusGuildCunningMoves(receipt, currentHajrUsed, movesAfterSecond)` returns only the remaining Hajr extra allowance. Ending ordinary movement forgoes at least one ordinary slot, so original movement counts zero and one both permit at most one Hajr move; an original count of two permits none. A newly played Hajr supplies that same one extra slot, while losing a previously recorded Hajr or claiming more moves than that allowance rejects. That original v1 bound remains compatible. Counted v2 supports genuinely replayed Hajr; v3 additionally records actual completed physical Ornithopter credits through the optional fourth current-count argument. The engine retains unfinished-flight rights separately; the helper does not reset or mutate the original movement counter. Its [pure tests](../tests/nexus-guild-cunning.test.ts) cover declined and used first actions, Hajr before and after, spent allowances, native ownership, exact signatures, JSON restoration and private-field getter traps. Integrated verification should exercise first shipment followed by moved/declined ordinary movement, all three second-shipment forms, Hajr before/after and already consumed, cancellation of only the selected native advantage, paid BG/entry continuations, native Homeworld routes, private offers, stale requests and actual SQLite restoration. Passing the pure receipt tests does not establish those engine or persistence behaviors. diff --git a/docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md b/docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md index 1a4b2e9c..d355869e 100644 --- a/docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md +++ b/docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md @@ -1,5 +1,31 @@ # Richese Ornithopter: movement integration audit +## Guild Cunning and earned Hajr movement — 18 October 2026 + +The [printed Guild Nexus](NEXUS_GUILD_RULES.md#primary-evidence) grants only a +second shipment, with Hajr required for subsequent movement. This checkpoint +connects the existing unused-entitlement replacement policy: either physical +Ornithopter mode may replace an earned unused Hajr allowance after Cunning. +Earlier completed flight credits remain counted, and an active two-group flight +retains its original second group without speculative credit. Only completion of +both groups earns the extra physical-move credit. Declaring Cunning during an +unfinished flight discards it before the original Nexus response resumes; ending +a later partial flight completes the turn without abandoned-group credit. + +This composition is explicitly provisional. It does not assert a card-specific +publisher clarification, grant an ordinary move or alter Advanced Hajr timing. +See the [runtime contract](NEXUS_GUILD_CUNNING_RUNTIME.md#physical-ornithopter-composition--18-october-2026) +for v3 original-count receipts, compatible old formats and continuation scope. +Earlier “separate/unavailable” observations below are historical within this +named combination. Wider modules, full modes and final assurance remain open. +The frozen13-file125/125 batch, types/lint/build and eight original-action/ +rendered-table continuations pass. Actual paid cards, worm/alliance/Nexus draw, +first2/second1 shipments and both modes preserve all earned counts. Eight minimal +policy proposals are legal; no frozen repair. Exact source binding and runtime +limits are recorded in the linked contract. New Semuta grammar has no direct +combined claim/capture programme; browser/deployed acceptance stays open. + + Audit: 2026-09-06. Document-only source and engine review. The canonical component is `richese-ornithopter`; this is distinct from ordinary Arrakeen/Carthag ornithopter access and the later Ecaz discovery token. No runtime effect or complete expansion compliance is claimed here. ## Advanced advisor card movement — 18 October 2026 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 05123b4b..4c853dae 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,16 @@ # Rule decision index +## Guild Cunning and physical Ornithopter — 18 October 2026 + +The printed Nexus grants a second shipment with Hajr required for later movement. +Under the standing recommended-composition instruction, the physical card may +replace an earned unused Hajr allowance. Completed flight credits preserve actual +movement counts; unfinished groups grant no extra credit when abandoned. New +v3 original-count receipts retain exact old receipt formats. This is provisional +composition, not publisher clarification or complete-combination acceptance. +[Canonical contract](NEXUS_GUILD_CUNNING_RUNTIME.md#physical-ornithopter-composition--18-october-2026). + + Updated 18 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. ## Fremen Nexus and Great Maker riding — 18 October 2026 diff --git a/game/bots.ts b/game/bots.ts index a3b28a6c..6dc5084c 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3328,7 +3328,7 @@ function policyActions(g: GameView): Action[] { const movementAvailable = nexusGuildMovementAvailable(g); const hajr = nexusGuildHajrAction(g); if (hajr) return [hajr]; - const cardMoves = nexusGuildCunningActive(g) ? [] : ornithopterMoves(g); + const cardMoves = ornithopterMoves(g); const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked ? discoveryFlightBotActions(g, destinations(g).filter(target => target.score > -1000) diff --git a/game/engine.ts b/game/engine.ts index 01447f6d..5922b64e 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1556,7 +1556,7 @@ export type Game = { } | { kind: 'ornithopterDiscard'; - source: 'move' | 'end'; + source: 'move' | 'end' | 'cunning'; flight: NonNullable; movement: CompletedMovement | null; resume: NonNullable['resume']; @@ -2031,7 +2031,7 @@ export type Game = { nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'}; nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[]; nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[]; - nexusGuildCunningHistory?: {receipt:NexusGuildCunningReceipt;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled';parent:string;movesAfter:number;hajrPlays?:number;shipment?:{kind:'reserve'|'guild'|'homeworld';frame:string;stage:'pending'|'complete'|'stopped'}|{kind:'declined';stage:'declined'};signature:string}[]; + nexusGuildCunningHistory?: {receipt:NexusGuildCunningReceipt;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled';parent:string;movesAfter:number;hajrPlays?:number;ornithopterExtraMoves?:number;shipment?:{kind:'reserve'|'guild'|'homeworld';frame:string;stage:'pending'|'complete'|'stopped'}|{kind:'declined';stage:'declined'};signature:string}[]; nexusGuildCunningLast?: {event:string;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled'}; nexusGuildSecretHistory?: {receipt:NexusGuildSecretAllyReceipt;kind:'reserve'|'cross'|'homeworld';frame:string;signature:string}[]; nexusGuildSecretLast?: {event:string}; @@ -6949,7 +6949,7 @@ function treacheryDiscardIntegrity(g: Game) { Number.isSafeInteger(flight.completed) && flight.completed >= 0 && owner.moved === flight.startingMove + flight.completed && - ['move', 'end'].includes(c.source) && + ['move', 'end', 'cunning'].includes(c.source) && (c.source === 'move' ? flight.completed === total : flight.completed < total) && @@ -6991,6 +6991,12 @@ function treacheryDiscardIntegrity(g: Game) { c.movement === null, 'An ended Ornithopter has no arrival to replay.', ); + if (c.source === 'cunning') { + requireRule(c.resume.response?.kind === 'nexusGuildCunning' && + c.resume.response.owner === owner.id, + 'The ended Ornithopter must resume its original Guild Cunning response.'); + validateGuildCunningResponse(g, c.resume.response); + } requireRule( c.stateSignature === ornithopterDiscardSignature(g, c), 'The retired Ornithopter no longer matches its saved movement obligations.', @@ -7503,7 +7509,7 @@ function semutaOfferSupported( continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleResolved' && !(continuation.kind === 'ornithopterDiscard' && - (continuation.source === 'end' || + (continuation.source === 'end' || continuation.source === 'cunning' || (continuation.source === 'move' && !g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') && !g.pendingArrivalOverlap))) && @@ -7645,7 +7651,9 @@ function semutaOfferSupported( continuation.kind === 'karamaEmperorShareDiscard' || continuation.kind === 'karamaIncomeDiscard' || continuation.kind === 'karamaShipmentDiscard' || - (!continuation.resume.response && !continuation.resume.decision && + ((continuation.kind === 'ornithopterDiscard' && continuation.source === 'cunning' + ? continuation.resume.response?.kind === 'nexusGuildCunning' + : !continuation.resume.response) && !continuation.resume.decision && !continuation.resume.pendingKarama && (continuation.kind === 'amalDiscard' ? continuation.resume.phaseOpening?.passed.length === 0 @@ -7982,7 +7990,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; if (next.source === 'move') finishMovedGroup(g, next.movement!); - else finishMovementTurn(g, next.flight.player); + else if (next.source === 'end') finishMovementTurn(g, next.flight.player); } else if (next.kind === 'ixAllyCard') { const buyer = getPlayer(g, next.player); const card = draw(g); @@ -15611,12 +15619,13 @@ function hajrPlayCount(g: Game, p: Player) { return count; } function movesAllowed(g: Game, p: Player) { - const cunning = currentGuildCunning(g,p.id); - if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(cunning.receipt,hajrPlayCount(g,p),cunning.movesAfter)) : 0); const flight = g.ornithopter; - return flight?.player === p.id && flight.turn === g.turn - ? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1) - : ordinaryMovementAllowance(hajrPlayCount(g,p), p.ornithopterExtraMoves); + if (flight?.player === p.id && flight.turn === g.turn) + return flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1); + const cunning = currentGuildCunning(g,p.id); + if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() => + nexusGuildCunningMoves(cunning.receipt,hajrPlayCount(g,p),cunning.movesAfter,p.ornithopterExtraMoves ?? 0)) : 0); + return ordinaryMovementAllowance(hajrPlayCount(g,p), p.ornithopterExtraMoves); } function hajrAvailable(g: Game, p: Player) { return p.hand.some(card => card.kind === 'special' && card.effect === 'hajr') && p.moved <= movesAllowed(g,p) && @@ -15686,7 +15695,6 @@ function ornithopterBlock( return 'Resolve the current interaction before declaring a movement.'; if (g.ornithopter) return 'Finish the already played Ornithopter movement first.'; - if (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.'; if (p.moved >= movesAllowed(g,p)) return 'Spend an unused ordinary or Hajr movement on Ornithopter.'; // Provisional composition: Kulon augments normal movement, not the @@ -15743,7 +15751,7 @@ function ornithopterIntegrity(g: Game) { } function finishOrnithopter( g: Game, - source: 'move' | 'end', + source: 'move' | 'end' | 'cunning', movement: CompletedMovement | null = null, ) { const flight = g.ornithopter; @@ -16322,7 +16330,14 @@ function completeMove(g: Game, move: MovementOrder) { if (flight?.player === id) { flight.completed++; if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) { - if (flight.mode === 'twoGroups') p.ornithopterExtraMoves = (p.ornithopterExtraMoves ?? 0) + 1; + if (flight.mode === 'twoGroups') { + p.ornithopterExtraMoves = (p.ornithopterExtraMoves ?? 0) + 1; + if (cunning) { + cunning.hajrPlays = hajrPlayCount(g,p); + cunning.ornithopterExtraMoves = p.ornithopterExtraMoves; + saveGuildCunning(g,cunning); + } + } finishOrnithopter(g, 'move', completed); return; } @@ -28391,7 +28406,8 @@ function guildCunningParent(g: Game) { } function guildCunningSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature,record.stage,record.parent,record.movesAfter,record.shipment ?? null, - ...(record.hajrPlays === undefined ? [] : [record.hajrPlays])]); + ...(record.hajrPlays === undefined ? [] : [record.hajrPlays]), + ...(record.ornithopterExtraMoves === undefined ? [] : [['ornithopter',record.ornithopterExtraMoves]])]); } function saveGuildCunning(g: Game, record: NonNullable[number]) { record.signature = guildCunningSignature(record); @@ -28402,7 +28418,7 @@ function projectedNexusGuildCunning(g: Game, owner: string) { if (!p || p.faction !== 'guild') return null; const record = currentGuildCunning(g,owner); if (record) return {offer:null,active:{event:record.receipt.event,stage:record.stage as 'pending'|'secondShipment'|'extraMove', - movesLeft:record.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(record.receipt,hajrPlayCount(g,p),record.movesAfter)) : 0, + movesLeft:record.stage === 'extraMove' ? movesAllowed(g,p) - p.moved : 0, hajrAvailable:record.stage !== 'pending' && hajrAvailable(g,p)}}; if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null; let blocked: string | null = null; @@ -28413,8 +28429,6 @@ function projectedNexusGuildCunning(g: Game, owner: string) { else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingShipment || g.pendingHomeworldShipment || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before declaring a second shipment.'; - else if (g.ornithopter || p.ornithopterExtraMoves || p.moved > ordinaryMovementAllowance(hajrPlayCount(g,p))) - blocked = 'Cunning combined with the physical Ornithopter movement card awaits its timing ruling.'; else if (g.nexusGuildCunningHistory?.some(r => r.receipt.turn === g.turn && r.receipt.owner === owner)) blocked = 'This turn’s Guild Cunning opportunity is already used.'; return {offer:{event:JSON.stringify(['nexusGuildCunning',g.turn,owner]),blocked},active:null}; @@ -28422,8 +28436,8 @@ function projectedNexusGuildCunning(g: Game, owner: string) { function declareGuildCunning(g: Game, p: Player, event: unknown) { const offer = projectedNexusGuildCunning(g,p.id)?.offer; requireRule(offer && !offer.blocked && event === offer.event,offer?.blocked ?? 'This Guild Cunning opportunity is stale.'); - const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrPlays:hajrPlayCount(g,p)})); - const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,hajrPlays:hajrPlayCount(g,p),signature:''}; + const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrPlays:hajrPlayCount(g,p),ornithopterExtraMoves:p.ornithopterExtraMoves ?? 0})); + const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,hajrPlays:hajrPlayCount(g,p),ornithopterExtraMoves:p.ornithopterExtraMoves ?? 0,signature:''}; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusGuildCunningHistory ??= []).push(record); saveGuildCunning(g,record); g.karamaShipping = null; @@ -28482,16 +28496,20 @@ function nexusGuildCunningIntegrity(g: Game) { requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,'Guild Cunning requires its original Nexus module and history.'); const events = new Set(); for (const [index,record] of history.entries()) { - requireRule(record && typeof record === 'object' && Object.keys(record).every(k => ['receipt','stage','parent','movesAfter','hajrPlays','shipment','signature'].includes(k)) && + requireRule(record && typeof record === 'object' && Object.keys(record).every(k => ['receipt','stage','parent','movesAfter','hajrPlays','ornithopterExtraMoves','shipment','signature'].includes(k)) && ['pending','secondShipment','extraMove','completed','canceled'].includes(record.stage) && typeof record.parent === 'string' && record.signature === guildCunningSignature(record) && !events.has(record.receipt?.event) && Number.isSafeInteger(record.movesAfter) && record.movesAfter >= 0 && (['completed','canceled'].includes(record.stage) || index === history.length - 1),'Guild Cunning has a changed or duplicated allowance record.'); nexusRule(() => validateNexusGuildCunning(g,record.receipt)); + requireRule((record.ornithopterExtraMoves === undefined || + (Number.isSafeInteger(record.ornithopterExtraMoves) && record.ornithopterExtraMoves >= 0)) && + (record.receipt.version !== 3 || record.ornithopterExtraMoves !== undefined), + 'Guild Cunning needs its actual completed Ornithopter count.'); if (record.hajrPlays !== undefined) { requireRule(Number.isSafeInteger(record.hajrPlays) && record.hajrPlays >= 0, 'Guild Cunning needs its actual earned Hajr count.'); - nexusRule(() => nexusGuildCunningMoves(record.receipt,record.hajrPlays!,record.movesAfter)); + nexusRule(() => nexusGuildCunningMoves(record.receipt,record.hajrPlays!,record.movesAfter,record.ornithopterExtraMoves ?? 0)); } else requireRule(record.receipt.version === 1 && record.movesAfter <= 1, 'The legacy Guild Cunning history has lost its original Hajr bound.'); if (record.shipment?.kind === 'declined') requireRule(Object.keys(record.shipment).sort().join(',') === 'kind,stage' && record.shipment.stage === 'declined' && ['extraMove','completed'].includes(record.stage), 'Guild Cunning has changed its declined second shipment.'); @@ -28517,9 +28535,10 @@ function nexusGuildCunningIntegrity(g: Game) { requireRule(g.status === 'playing' && g.phase === 5 && g.active === owner.id && active.parent === guildCunningParent(g) && owner.moved === active.receipt.before.moved + active.movesAfter && (active.hajrPlays === undefined || active.hajrPlays === hajrPlayCount(g,owner)) && + (active.ornithopterExtraMoves === undefined || active.ornithopterExtraMoves === (owner.ornithopterExtraMoves ?? 0)) && owner.shipped === (active.stage === 'extraMove' && (active.shipment?.kind !== 'declined' || active.movesAfter > 0) ? true : active.receipt.before.shipped) && g.nexusCards.cards.discard.includes('guild'), 'Guild Cunning changed its original active player, queue, shipment or movement allowance.'); - nexusRule(() => nexusGuildCunningMoves(active.receipt,hajrPlayCount(g,owner),active.movesAfter)); + nexusRule(() => nexusGuildCunningMoves(active.receipt,hajrPlayCount(g,owner),active.movesAfter,owner.ornithopterExtraMoves ?? 0)); for (const response of responses) validateGuildCunningResponse(g,response!); if (active.shipment?.stage === 'pending') { const suspended = g.pendingGuildBetrayal?.continuation; @@ -34287,7 +34306,11 @@ function applyActionInner( g.phase === 5 && g.active === id, 'Wait for your movement turn.', ); - if (action.nexus !== undefined) { declareGuildCunning(g,p,action.nexus); return g; } + if (action.nexus !== undefined) { + declareGuildCunning(g,p,action.nexus); + if (g.ornithopter?.player === id) finishOrnithopter(g, 'cunning'); + return g; + } // Reject unsupported concealed custody before retiring a played movement // card and parking its native ending continuation. requireAlliedSeparationCustody(g, p); diff --git a/game/nexus-guild-cunning.ts b/game/nexus-guild-cunning.ts index 7f7cefc8..14d04084 100644 --- a/game/nexus-guild-cunning.ts +++ b/game/nexus-guild-cunning.ts @@ -9,12 +9,21 @@ export type NexusGuildCunningBefore = { moved: number; hajrUsed: boolean; hajrPlays?: never; + ornithopterExtraMoves?: never; }; export type NexusGuildCunningCountedBefore = { shipped: boolean; moved: number; hajrPlays: number; hajrUsed?: never; + ornithopterExtraMoves?: never; +}; +export type NexusGuildCunningMovementBefore = { + shipped: boolean; + moved: number; + hajrPlays: number; + ornithopterExtraMoves: number; + hajrUsed?: never; }; type NexusGuildCunningReceiptFields = { event: string; @@ -33,10 +42,16 @@ export type NexusGuildCunningCountedReceipt = NexusGuildCunningReceiptFields & { version: 2; before: NexusGuildCunningCountedBefore; }; +export type NexusGuildCunningMovementReceipt = NexusGuildCunningReceiptFields & { + version: 3; + before: NexusGuildCunningMovementBefore; +}; export type NexusGuildCunningReceipt = | NexusGuildCunningLegacyReceipt - | NexusGuildCunningCountedReceipt; -type Before = NexusGuildCunningBefore | NexusGuildCunningCountedBefore; + | NexusGuildCunningCountedReceipt + | NexusGuildCunningMovementReceipt; +type Before = NexusGuildCunningBefore | NexusGuildCunningCountedBefore | + NexusGuildCunningMovementBefore; function requireGuild(value: unknown, message: string): asserts value { if (!value) throw new Error(message); @@ -75,17 +90,24 @@ function validPlayCount(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && Number.isSafeInteger(value + 1); } -function validateBefore(before: Before, version: 1 | 2) { +function validateBefore(before: Before, version: 1 | 2 | 3) { + const expectedKeys = version === 1 ? 'hajrUsed,moved,shipped' : version === 2 + ? 'hajrPlays,moved,shipped' : 'hajrPlays,moved,ornithopterExtraMoves,shipped'; requireGuild( plain(before) && - Object.keys(before).sort().join(',') === - (version === 1 ? 'hajrUsed,moved,shipped' : 'hajrPlays,moved,shipped') && + Object.keys(before).sort().join(',') === expectedKeys && typeof before.shipped === 'boolean' && (version === 1 ? typeof before.hajrUsed === 'boolean' : validPlayCount(before.hajrPlays)) && + (version !== 3 || validPlayCount(before.ornithopterExtraMoves)) && + Number.isSafeInteger(1 + (version === 1 ? Number(before.hajrUsed) : before.hajrPlays!) + + (version === 3 ? before.ornithopterExtraMoves! : 0)) && Number.isSafeInteger(before.moved) && before.moved >= 0 && - before.moved <= 1 + (version === 1 ? Number(before.hajrUsed) : before.hajrPlays!), - 'Guild Cunning needs the original ordinary shipment and Hajr movement allowance.', + before.moved <= 1 + (version === 1 ? Number(before.hajrUsed) : before.hajrPlays!) + + (version === 3 ? before.ornithopterExtraMoves! : 0), + version === 3 + ? 'Guild Cunning needs the original ordinary shipment, Hajr and completed Ornithopter movement allowance.' + : 'Guild Cunning needs the original ordinary shipment and Hajr movement allowance.', ); } function signature(receipt: NexusGuildCunningReceipt) { @@ -98,8 +120,11 @@ function signature(receipt: NexusGuildCunningReceipt) { receipt.phase, receipt.mode, receipt.roster.map((p) => [p.id, p.faction]), - [receipt.before.shipped, receipt.before.moved, - receipt.version === 1 ? receipt.before.hajrUsed : receipt.before.hajrPlays], + receipt.version === 3 + ? [receipt.before.shipped, receipt.before.moved, receipt.before.hajrPlays, + receipt.before.ornithopterExtraMoves] + : [receipt.before.shipped, receipt.before.moved, + receipt.version === 1 ? receipt.before.hajrUsed : receipt.before.hajrPlays], ]); } /** Validates historical facts, not current cards, alliances, resources or movement. @@ -113,7 +138,7 @@ export function validateNexusGuildCunning( plain(receipt) && Object.keys(receipt).sort().join(',') === 'before,event,mode,owner,phase,roster,signature,turn,version' && - (receipt.version === 1 || receipt.version === 2) && + (receipt.version === 1 || receipt.version === 2 || receipt.version === 3) && receipt.phase === 5 && receipt.mode === 'cunning' && Number.isSafeInteger(receipt.turn) && @@ -146,6 +171,11 @@ export function createNexusGuildCunning( owner: string, before: NexusGuildCunningCountedBefore, ): NexusGuildCunningCountedReceipt; +export function createNexusGuildCunning( + context: NexusGuildCunningContext & { phase: number; active: string | null }, + owner: string, + before: NexusGuildCunningMovementBefore, +): NexusGuildCunningMovementReceipt; export function createNexusGuildCunning( context: NexusGuildCunningContext & { phase: number; active: string | null }, owner: string, @@ -165,7 +195,8 @@ export function createNexusGuildCunning( ), 'Guild Cunning requires the unallied native Guild at the end of its own combined turn.', ); - const version = plain(before) && Object.hasOwn(before, 'hajrPlays') ? 2 : 1; + const version = plain(before) && Object.hasOwn(before, 'ornithopterExtraMoves') ? 3 : + plain(before) && Object.hasOwn(before, 'hajrPlays') ? 2 : 1; validateBefore(before, version); const fields: Omit = { event: JSON.stringify(['nexusGuildCunning', context.turn, owner]), @@ -177,18 +208,22 @@ export function createNexusGuildCunning( }; const receipt: NexusGuildCunningReceipt = version === 1 ? { version: 1, ...fields, before: { ...before as NexusGuildCunningBefore }, signature: '' } - : { version: 2, ...fields, before: { ...before as NexusGuildCunningCountedBefore }, signature: '' }; + : version === 2 + ? { version: 2, ...fields, before: { ...before as NexusGuildCunningCountedBefore }, signature: '' } + : { version: 3, ...fields, before: { ...before as NexusGuildCunningMovementBefore }, signature: '' }; receipt.signature = signature(receipt); validateNexusGuildCunning(context, receipt); return receipt; } -/** Ending ordinary movement forgoes at least its first slot. Every actual Hajr - * play supplies one extra slot; the engine owns card custody and play timing. - * Legacy boolean callers/receipts retain their original zero-or-one meaning. */ +/** Ending ordinary movement forgoes at least its first slot. Actual Hajr plays + * and completed two-group Ornithopter uses supply their earned extra slots. + * The engine owns card custody, play timing and any still-active flight's rights; + * no speculative flight credit is supplied here. Legacy defaults are unchanged. */ export function nexusGuildCunningMoves( receipt: NexusGuildCunningReceipt, currentHajrUsed: boolean | number, movesAfterSecond = 0, + currentOrnithopterExtraMoves = 0, ): number { validateNexusGuildCunning( { turn: receipt.turn, players: receipt.roster }, @@ -196,13 +231,19 @@ export function nexusGuildCunningMoves( ); const currentCount = typeof currentHajrUsed === 'boolean' ? Number(currentHajrUsed) : currentHajrUsed; const beforeCount = receipt.version === 1 ? Number(receipt.before.hajrUsed) : receipt.before.hajrPlays; + const beforeExtraMoves = receipt.version === 3 ? receipt.before.ornithopterExtraMoves : 0; requireGuild( validPlayCount(currentCount) && currentCount >= beforeCount && + validPlayCount(currentOrnithopterExtraMoves) && currentOrnithopterExtraMoves >= beforeExtraMoves && + Number.isSafeInteger(1 + currentCount + currentOrnithopterExtraMoves) && Number.isSafeInteger(movesAfterSecond) && movesAfterSecond >= 0, - 'Guild Cunning has changed its remaining Hajr movement.', + receipt.version === 3 || currentOrnithopterExtraMoves !== 0 + ? 'Guild Cunning has changed its remaining Hajr or Ornithopter movement.' + : 'Guild Cunning has changed its remaining Hajr movement.', ); - const available = Math.max(0, 1 + currentCount - Math.max(1, receipt.before.moved)); + const available = Math.max(0, 1 + currentCount + currentOrnithopterExtraMoves - + Math.max(1, receipt.before.moved)); requireGuild( movesAfterSecond <= available, 'Guild Cunning cannot supply another ordinary movement.', diff --git a/game/reference.ts b/game/reference.ts index e11ebaa5..aca266c0 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -902,7 +902,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A validated declaration moves the physical card from your hand to the public played-card area. It remains there through genuine CHOAM, Ixian or other arrival decisions and is discarded once when the chosen use ends. Random hand effects act on cards still in hand. Ending your movement closes any remaining card movement without undoing a completed group.', 'The saved event binds retries and the remaining original-group data. Refresh and reconnect preserve that event. After disposal, recovery preserves the completed forces and marker event before opening arrival decisions or advancing an early-ended turn; it does not move the group again. Missing saved group data cannot be reconstructed from merged forces and blocks the damaged continuation instead of allowing reuse.', 'Under the provisional composition, Ornithopter replaces one unused ordinary or Hajr movement. A prior move stays spent and cannot become the card’s first group retrospectively. One Hajr play plus a two-group flight permits three group movements; Hajr may reuse a card-moved group. Ending early abandons the unused card group and ends your turn. The single physical Hajr may grant another move after legitimate recovery and a new legal play.', - 'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advanced advisors may use both physical card modes. They do not grant city access themselves; two-group mode uses current fighter city access at each move, while range3 is independent. Empty arrivals flip normally, and fresh-advisor locks and original-group limits still apply. Guild Cunning/card and wider optional combinations remain unfinished.', + 'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advanced advisors may use both physical card modes. They do not grant city access themselves; two-group mode uses current fighter city access at each move, while range3 is independent. Empty arrivals flip normally, and fresh-advisor locks and original-group limits still apply. After Guild Cunning, the physical card may provisionally replace one earned unused Hajr allowance. Cunning itself adds no ordinary move; an active two-group flight keeps its original remaining group. Declaring Cunning abandons an unfinished earlier flight without extra credit. Wider optional combinations remain unfinished.', 'All four AI profiles use their own projected movement choices and original-group limits. They can use a longer route, split distinct groups, finish interrupted movement and end optional movement when no legal second group remains. Complete Richese-game calibration is not yet certified.', ], related: [ @@ -916,19 +916,19 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Wider combinations and broader acceptance remain unfinished.', + 'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Guild Cunning now retains actual earlier credits and permits a physical card to replace an unused Hajr grant, with active-flight and early-end suffixes. New v3 receipts preserve old formats. Wider combinations and broader acceptance remain unfinished.', }, { area: 'Player controls', status: 'Partial', detail: - 'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Advanced timing and advisor/city access; automatic disposal adds no confirmation.', + 'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Guild Cunning prerequisites/early ending, Advanced timing and advisor/city access; automatic disposal adds no confirmation.', }, { area: 'AI', status: 'Partial', detail: - 'Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.', + 'Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement, including post-Cunning card groups. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.', }, { area: 'Documentation', @@ -940,9 +940,14 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - '18 October advisor scope:10-file87/87, types/lint/build and three original-action/rendered-table programmes pass. Actual host/setup, paid cache1, native accompaniment and paid BG reinforcement cover advisor range3, distinct advisor/fighter groups and loss of city access. Eight minimal policy proposals are legal. Controlled geometry and isolated controls remain separately labelled; no select/submit browser click. Earlier Hajr evidence remains separate. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance; full combinations/assurance/modes remain open.', + '18 October Guild Cunning scope:13-file125/125, types/lint/build and eight original-action/rendered-table continuations pass. Public ready/start, cache1/Hajr1 purchases, worm/alliance/Guild draw and paid first2/second1 shipments cover both modes, earlier flights and Basic early ending. Eight minimal policy proposals are legal; unearned/spent Hajr rejects. Old receipt formats remain covered. Earlier advisor/Hajr evidence is separate. Actual accepted drafts render without select/submit browser clicks. New Semuta grammar lacks direct combined claim/capture proof; wider combinations, full modes, final assurance and visual/browser/deployed acceptance remain open.', evidence: [ 'game/ornithopter.ts', + 'game/nexus-guild-cunning.ts', + 'tests/nexus-guild-cunning-ornithopter.test.ts', + 'tests/fixture-guild-cunning-ornithopter.ts', + 'tests/guild-cunning-ornithopter-engine.test.ts', + 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'components/ornithopter-movement.tsx', 'tests/ornithopter-advisor-rules.test.ts', 'tests/ornithopter-advisors-engine.test.ts', @@ -4781,7 +4786,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Forces, elite identity, destination restrictions, native Homeworld sources and movement remain unchanged. Fremen retain free reinforcement. Native Guild rate cancellation changes the one-counter sand bill from1 to2 without canceling the Nexus source; an already canceled rate is reflected in the quote. Unfunded declared Nexus stays spent without refund/reusable coupon while ordinary shipment remains available under the selected affordability policy. An independently purchased ordinary price remains separate. This is no concealed marker or extra shipment.', 'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.', 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.', - 'Guild Cunning is declared when finishing the ordinary Shipment and Movement turn. It spends the Nexus and opens its native Karama response. Allowance grants one second native shipment at normal Guild prices, including supported cross, return and Homeworld routes; cancellation preserves the first shipment and movement. Every later movement must spend an earned unused Hajr grant; there is no new ordinary move. A genuinely recovered and legally replayed Hajr adds another grant, with fresh post-movement timing in Advanced. Classic core retains original funding through Guild fee acknowledgement; other envelopes keep their source limits. The physical Ornithopter card combination remains separate.', + 'Guild Cunning is declared when finishing the ordinary Shipment and Movement turn. It spends the Nexus and opens its native Karama response. Allowance grants one second native shipment at normal Guild prices, including supported cross, return and Homeworld routes; cancellation preserves the first shipment and movement. Every later movement must spend an earned unused Hajr grant; there is no new ordinary move. A genuinely recovered and legally replayed Hajr adds another grant, with fresh post-movement timing in Advanced. Classic core retains original funding through Guild fee acknowledgement; other envelopes keep their source limits. The physical Ornithopter may provisionally replace one unused Hajr grant. Both original card groups remain legal until completed or abandoned, with extra credit only after both actually move. Earlier completed flight credits remain counted; declaring Cunning during an unfinished flight abandons its remaining group before the original Nexus response resumes. This adds no ordinary move.', 'Cunning native cross/return permission and rate use separate unpaid controls while original shipped history stays unchanged. A denial within its second opportunity allows a legal reserve replacement; denial of an earlier ordinary opportunity does not remove that new signed grant. Typed sources, original prices and approval stay bound; neither first payment nor ordinary movement reopens. Independent purchased price, immune Junction and other card sources remain distinct.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to the holder’s own reserves, including the native-Homeworld return when Homeworlds are enabled, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Arbitrary foreign-world transport and concealed No-Fields are not granted.', 'Borrowed Guild/Richese tariffs do not change native faction or physical reserve origin. Non-Guild/non-Fremen off-planet arrivals can accrue Heighliners once for collection at the original phase end. A paid borrowed Guild route from Fremen southern reserves is still on-planet and does not trigger it; stopped and native-Guild-only shipments likewise remain excluded. The existing Emperor bank-auction purchase retains the actual buyer wallet and creates no industry activity.', diff --git a/tests/fixture-guild-cunning-ornithopter.ts b/tests/fixture-guild-cunning-ornithopter.ts new file mode 100644 index 00000000..c4623f8d --- /dev/null +++ b/tests/fixture-guild-cunning-ornithopter.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { location, splitLocation, TERRITORIES } from '../game/board'; +import { botEntryAllowed, groundMoveRouteDistance } from '../game/bot-mobility'; +import { nexusGuildCunningAction } from '../game/nexus-guild-cunning-options'; +import type { OrnithopterMode } from '../game/ornithopter'; +import { richeseCards } from '../game/richese-cards'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +const card = richeseCards().find(card => card.effect === 'ornithopter'); assert.ok(card); +export const GUILD_CUNNING_ORNITHOPTER = card.id; +export type GuildCunningOrnithopterStep = { actor: string; action: Action }; +export const guildCunningOrnithopterPlayer = (game: Game, actor = 'guild') => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const guildCunningOrnithopterClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal; +export function actGuildCunningOrnithopter(game: Game, actor: string, action: Action, + actions: GuildCunningOrnithopterStep[] = []): Game { + const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action)); + actions.push({ actor, action: structuredClone(action) }); return next; +} +/** Consume original neutral native controls without choosing an optional + * power, physical hand substitution or private Battle Plan. */ +export function nextGuildCunningOrnithopterStep(game: Game): GuildCunningOrnithopterStep | null { + if (game.pendingGuildBetrayal) { + const actor = game.pendingGuildBetrayal.required.find(actor => !game.pendingGuildBetrayal!.passed.includes(actor)); + assert.ok(actor); return { actor, action: { type: 'guildBetrayalPass', event: game.pendingGuildBetrayal.invoice.event } }; + } + if (!game.response && !game.phaseOpening) { + const decision = game.decision; + if (decision?.kind === 'nexusIxianReplacement') return { actor: decision.player, + action: { type: 'nexusIxianReplacementPass', event: decision.event } }; + if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } }; + if (decision?.kind === 'richeseAllyOpportunity') return { actor: decision.player, action: { type: 'decision', decline: true } }; + if (decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === GUILD_CUNNING_ORNITHOPTER)) + return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event, + card: GUILD_CUNNING_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } }; + if (decision?.kind === 'richeseUnbid') return { actor: decision.player, + action: { type: 'decision', event: game.richeseBidding!.event, keep: false } }; + if (!decision && game.nexusCards?.phase?.stage === 'drawing') { + const phase = game.nexusCards.phase, actor = phase.eligible.find(actor => !phase.done.includes(actor)); + assert.ok(actor); return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } }; + } + } + if (guildCunningOrnithopterClean(game) && game.richeseAuction?.cardId === GUILD_CUNNING_ORNITHOPTER && + !game.richeseAuction.outcome && game.richeseAuction.active === 'guild' && game.richeseAuction.bid === 0) + return { actor: 'guild', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } }; + if (guildCunningOrnithopterClean(game) && game.auction?.active === 'guild' && game.auction.bid === 0 && + game.auction.cards[game.auction.index].effect === 'hajr') + return { actor: 'guild', action: { type: 'bid', amount: 1, allyPayment: 0 } }; + return nextRicheseStrongholdsNativeStep(game); +} +export function advanceGuildCunningOrnithopter(game: Game, until: (state: Game) => boolean, + actions: GuildCunningOrnithopterStep[] = []): Game { + for (let count = 0; count < 2400; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Cunning/Ornithopter programme ended before its owned control'); + const step = nextGuildCunningOrnithopterStep(game); assert.ok(step, 'Complete the actual private choice before advancing'); + game = actGuildCunningOrnithopter(game, step.actor, step.action, actions); + } + throw Error('The original Cunning/Ornithopter programme did not reach its finite boundary'); +} +function orderBlow(game: Game, worm = false) { + const front: Game['spiceDeck'] = []; + if (worm) { + const at = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper); + assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]); + } + for (let count = 0; count < (game.advanced ? 2 : 1); count++) { + const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0); + front.push(game.spiceDeck.splice(at, 1)[0]); + } + game.spiceDeck.unshift(...front); +} + +/** Public original host/setup. Native Guild buys the physical cache card and + * normal Hajr, then receives its actual Guild Nexus through an original closing + * draw after Richese/Emperor form a reciprocal alliance at the real worm. Only + * still-unopened deck order/scalar lotteries are selected; no held Nexus, + * Treachery, force, money, clock or completed grant is installed. */ +export function createGuildCunningOrnithopterFixture(options: { advanced?: boolean } = {}) { + const advanced = options.advanced ?? false; + let game = createGame('GUILDCUNNINGORNITHOPTER', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); + for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const actions: GuildCunningOrnithopterStep[] = []; + const act = (actor: string, action: Action) => { game = actGuildCunningOrnithopter(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceGuildCunningOrnithopter(game, until, actions); }; + act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true }); + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + orderBlow(game); + reach(state => state.phase === 2 && guildCunningOrnithopterClean(state)); + const at = game.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0, 'Acquire the still-undealt original Hajr'); + const hajr = game.deck[at].id; game.deck.unshift(game.deck.splice(at, 1)[0]); + const beforePurchases = structuredClone(game); + reach(state => guildCunningOrnithopterPlayer(state).hand.some(card => card.id === GUILD_CUNNING_ORNITHOPTER) && + guildCunningOrnithopterPlayer(state).hand.some(card => card.id === hajr)); + const acquired = structuredClone(game); + reach(state => state.turn === 2 && state.phase === 1 && guildCunningOrnithopterClean(state)); + const afterFirstTurn = structuredClone(game); + orderBlow(game, true); + reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && guildCunningOrnithopterClean(state)); + const beforeAlliance = structuredClone(game); + act('richese', { type: 'alliance', target: 'emperor' }); + act('emperor', { type: 'alliance', target: 'richese' }); + reach(state => state.nexusCards?.phase?.stage === 'drawing' && guildCunningOrnithopterClean(state)); + const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('guild'); assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); + const beforeNexusDraw = structuredClone(game), draw: GuildCunningOrnithopterStep = { actor: 'guild', action: { + type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild ?? null, choice: 'draw', ownRedraws: 0, + } }; + act(draw.actor, draw.action); + const afterNexusDraw = structuredClone(game); + reach(state => state.turn === 2 && state.phase === 5 && state.active === 'guild' && guildCunningOrnithopterClean(state)); + const beforeFirstShipment = structuredClone(game), firstShipment: GuildCunningOrnithopterStep = { actor: 'guild', action: { + type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0, + } }; + act(firstShipment.actor, firstShipment.action); reach(guildCunningOrnithopterClean); + return { game, lobby, setup, afterSetup, beforePurchases, acquired, afterFirstTurn, beforeAlliance, + beforeNexusDraw, draw, afterNexusDraw, beforeFirstShipment, firstShipment, + afterFirstShipment: structuredClone(game), hajr, card: GUILD_CUNNING_ORNITHOPTER, owner: 'guild', + source: 'imperial_basin:9', sourceActionCount: actions.length, actions, + staging: ['Original host selects Nexus before setup. Only unopened Hajr, Spice and closing Guild Nexus positions plus scalar lotteries are selected. Cache1/normal1 purchases, real worm/alliance/draw and native paid reserve shipment2 supply every card, counter, coin and grant source.'] }; +} +export type GuildCunningOrnithopterFixture = ReturnType; + +export function guildCunningOrnithopterMove(game: Game, from: string, options: { + range?: number; mode?: OrnithopterMode; +} = {}): Action { + const view = viewGame(game, 'guild'), own = view.players.find(player => player.id === view.me)!; + assert.ok((own.forces[from] ?? 0) > 0); + const target = TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock') + .flatMap(place => place.sectors.map(sector => location(place.id, sector))) + .find(key => { + const at = splitLocation(key); + return game.players.every(player => Object.entries(player.forces).every(([source, count]) => + count === 0 || splitLocation(source).territory !== at.territory)) && + botEntryAllowed(view, own, at.territory, at.sector, 'move', splitLocation(from).territory) && + groundMoveRouteDistance(view, own, from, key) === (options.range ?? 1); + }); + assert.ok(target, 'The original Guild group needs an empty printed destination at the selected range'); + return { type: 'move', from, amount: 1, elite: 0, ...splitLocation(target), + ...(options.mode ? { movementCard: GUILD_CUNNING_ORNITHOPTER, ornithopter: options.mode } + : view.ornithopter?.active ? { ornithopterEvent: view.ornithopter.active.event } : {}) }; +} + +export function completeGuildCunningOrnithopter(fixture: GuildCunningOrnithopterFixture, options: { + mode?: OrnithopterMode; position?: 'before' | 'after'; early?: 'beforeCunning' | 'afterCunning'; +} = {}) { + const mode = options.mode ?? 'twoGroups', position = options.early === 'beforeCunning' ? 'before' : options.position ?? 'after'; + assert.ok(!options.early || mode === 'twoGroups', 'Only a two-group flight has a group to abandon'); + assert.ok(options.early !== 'afterCunning' || position === 'after'); + let game = structuredClone(fixture.game), firstFlightGroup: Game | null = null, finishedFlight: Game | null = null; + let firstFlightDestination = ''; + const actions: GuildCunningOrnithopterStep[] = [], moves: { + kind: 'ordinary' | 'flight-before' | 'flight-after' | 'post-cunning-hajr'; + before: Game; step: GuildCunningOrnithopterStep; after: Game; + }[] = []; + const act = (actor: string, action: Action) => { game = actGuildCunningOrnithopter(game, actor, action, actions); }; + const settle = () => { game = advanceGuildCunningOrnithopter(game, guildCunningOrnithopterClean, actions); }; + const hajr = () => { act(fixture.owner, { type: 'card', card: fixture.hajr }); settle(); }; + const move = (kind: typeof moves[number]['kind'], from: string, settings: Parameters[2] = {}) => { + const before = structuredClone(game), step: GuildCunningOrnithopterStep = { + actor: fixture.owner, action: guildCunningOrnithopterMove(game, from, settings), + }; + act(step.actor, step.action); settle(); + moves.push({ kind, before, step, after: structuredClone(game) }); + return location(String(step.action.territory), Number(step.action.sector)); + }; + if (!game.advanced) hajr(); + if (position === 'after') move('ordinary', fixture.source); + else { + firstFlightDestination = move('flight-before', fixture.source, { mode, range: mode === 'range3' ? 3 : 1 }); + firstFlightGroup = structuredClone(game); + } + if (game.advanced) hajr(); + if (position === 'before' && mode === 'twoGroups' && options.early !== 'beforeCunning') + move('flight-before', fixture.source); + if (position === 'before' && options.early !== 'beforeCunning') finishedFlight = structuredClone(game); + const beforeCunning = structuredClone(game), offer = viewGame(game, fixture.owner).nexusGuildCunning?.offer; + assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The actual Guild must retain its Cunning declaration'); + const declaration = nexusGuildCunningAction(viewGame(game, fixture.owner), offer.event); assert.ok(declaration); + act(fixture.owner, declaration); + const declared = structuredClone(game); + settle(); + const allowed = structuredClone(game), view = viewGame(game, fixture.owner), own = view.players.find(player => player.id === fixture.owner)!; + const secondTarget = TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock') + .flatMap(place => place.sectors.map(sector => ({ territory: place.id, sector }))) + .find(target => game.players.every(player => Object.entries(player.forces).every(([source, count]) => + count === 0 || splitLocation(source).territory !== target.territory)) && + botEntryAllowed(view, own, target.territory, target.sector, 'ship')); + assert.ok(secondTarget, 'Pay for a positive native second shipment into an actually empty territory'); + const beforeSecondShipment = structuredClone(game), secondShipment: GuildCunningOrnithopterStep = { actor: fixture.owner, + action: { type: 'ship', ...secondTarget, amount: 1, elite: 0, allyPayment: 0 } }; + act(secondShipment.actor, secondShipment.action); settle(); + const afterSecondShipment = structuredClone(game), secondSource = location(secondTarget.territory, secondTarget.sector); + if (position === 'after') { + firstFlightDestination = move('flight-after', secondSource, { mode, range: mode === 'range3' ? 3 : 1 }); + firstFlightGroup = structuredClone(game); + if (mode === 'twoGroups' && options.early !== 'afterCunning') move('flight-after', fixture.source); + if (options.early !== 'afterCunning') finishedFlight = structuredClone(game); + } else move('post-cunning-hajr', secondSource); + const completed = structuredClone(game); + if (game.phase === 5 && game.active === fixture.owner) act(fixture.owner, { type: 'endMovement' }); + return { game, completed, beforeCunning, declared, allowed, declaration, beforeSecondShipment, + secondShipment, afterSecondShipment, secondSource, firstFlightGroup: firstFlightGroup!, firstFlightDestination, + finishedFlight, moves, actions, mode, position, early: options.early ?? null }; +} diff --git a/tests/guild-cunning-ornithopter-engine.test.ts b/tests/guild-cunning-ornithopter-engine.test.ts new file mode 100644 index 00000000..8b8103e5 --- /dev/null +++ b/tests/guild-cunning-ornithopter-engine.test.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { nexusGuildCunningAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable } from '../game/nexus-guild-cunning-options'; +import { + actGuildCunningOrnithopter, advanceGuildCunningOrnithopter, completeGuildCunningOrnithopter, createGuildCunningOrnithopterFixture, + guildCunningOrnithopterClean, + guildCunningOrnithopterMove, guildCunningOrnithopterPlayer as player, type GuildCunningOrnithopterFixture, +} from './fixture-guild-cunning-ornithopter'; + +const own = (game: Game) => viewGame(game, 'guild').players.find(player => player.id === 'guild')!; +const record = (game: Game) => game.nexusGuildCunningHistory!.at(-1)!; +function source(f: GuildCunningOrnithopterFixture) { + assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'nexusCards' && step.action.enabled)); + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1)); + assert.ok(player(f.acquired).hand.some(card => card.id === f.card)); + assert.ok(player(f.acquired).hand.some(card => card.id === f.hajr)); + assert.equal(f.beforeNexusDraw.nexusCards!.cards!.hands.guild, null); + assert.equal(f.beforeNexusDraw.nexusCards!.cards!.deck[0], 'guild'); + assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands.guild, 'guild'); + assert.equal(player(f.afterNexusDraw).ally ?? null, null); + assert.equal(player(f.afterNexusDraw, 'richese').ally, 'emperor'); + assert.equal(player(f.afterNexusDraw, 'emperor').ally, 'richese'); + assert.equal(player(f.beforeFirstShipment).spice - player(f.afterFirstShipment).spice, 2); + assert.equal(player(f.beforeFirstShipment).reserves - player(f.afterFirstShipment).reserves, 2); + assert.equal(player(f.game).forces[f.source], 2); + assert.equal(player(f.game).moved, 0); + assert.equal(player(f.game).shipped, true); + assert.ok(player(f.game).spice >= 1, 'The original Guild retains funds for the positive second shipment'); + assert.equal(f.sourceActionCount, f.actions.length); +} +function settled(f: GuildCunningOrnithopterFixture, result: ReturnType, count: number) { + assert.equal(player(result.completed).moved, count); + assert.equal(player(result.completed).shipped, true); + assert.equal(player(result.completed).hajrPlays, 1); + assert.equal(result.completed.discard.filter(card => card.id === f.hajr).length, 1); + assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1); + assert.equal(result.game.ornithopter ?? null, null); + assert.equal(result.game.nexusCards!.cards!.hands.guild, null); + assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1); + assert.equal(player(result.beforeSecondShipment).spice - player(result.afterSecondShipment).spice, 1); + assert.equal(player(result.beforeSecondShipment).reserves - player(result.afterSecondShipment).reserves, 1); + assert.equal(player(result.completed).spice, player(result.afterSecondShipment).spice); + assert.equal(record(result.game).stage, 'completed'); + assert.equal(record(result.game).shipment!.kind, 'reserve'); + assert.equal(record(result.game).shipment!.stage, 'complete'); + assert.equal(result.actions.at(-1)!.action.type, 'endMovement'); +} + +void test('genuine Basic Guild Cunning permits both paid physical Ornithopter modes only through its unused Hajr move', () => { + const f = createGuildCunningOrnithopterFixture(); + source(f); + for (const mode of ['range3', 'twoGroups'] as const) { + const result = completeGuildCunningOrnithopter(f, { mode }); + assert.equal(player(result.beforeCunning).moved, 1); + assert.equal(player(result.beforeCunning).hajrPlays, 1); + assert.equal(result.declaration.nexus, viewGame(result.beforeCunning, f.owner).nexusGuildCunning!.offer!.event); + const receipt = record(result.allowed).receipt; + assert.equal(receipt.version, 3); + if (receipt.version === 3) assert.deepEqual(receipt.before, + { shipped: true, moved: 1, hajrPlays: 1, ornithopterExtraMoves: 0 }); + assert.equal(record(result.allowed).stage, 'secondShipment'); + assert.equal(nexusGuildMovementAvailable(viewGame(result.allowed, f.owner)), false); + assert.equal(nexusGuildShipmentAvailable(viewGame(result.allowed, f.owner)), true); + assert.equal(nexusGuildMovementAvailable(viewGame(result.afterSecondShipment, f.owner)), true); + assert.equal(viewGame(result.afterSecondShipment, f.owner).ornithopter!.blocked, null); + assert.equal(result.moves[1].step.action.from, result.secondSource, + 'The genuine newly shipped counter can take the Hajr-backed card flight'); + if (mode === 'twoGroups') { + assert.equal(player(result.firstFlightGroup).moved, 2); + assert.equal(own(result.firstFlightGroup).movesAllowed, 3); + assert.equal(viewGame(result.firstFlightGroup, f.owner).ornithopter!.active!.remaining, 1); + assert.equal(nexusGuildMovementAvailable(viewGame(result.firstFlightGroup, f.owner)), true); + const repeat = guildCunningOrnithopterMove(result.firstFlightGroup, result.firstFlightDestination); + assert.throws(() => actGuildCunningOrnithopter(result.firstFlightGroup, f.owner, repeat), /cohort|unmoved|original|force/i); + assert.equal(player(result.completed).ornithopterExtraMoves, 1); + assert.equal(record(result.completed).movesAfter, 2); + } else assert.equal(record(result.completed).movesAfter, 1); + settled(f, result, mode === 'twoGroups' ? 3 : 2); + } +}); + +void test('genuine Advanced Guild earns Hajr after its actual move before Cunning and either physical card mode', () => { + const f = createGuildCunningOrnithopterFixture({ advanced: true }); + source(f); + for (const mode of ['range3', 'twoGroups'] as const) { + const result = completeGuildCunningOrnithopter(f, { mode }); + const movement = result.moves[0]; + assert.equal(own(movement.before).hajrPlays, 0); + assert.equal(own(movement.after).hajrAvailable, true); + assert.equal(own(result.beforeCunning).hajrPlays, 1); + assert.equal(own(result.afterSecondShipment).hajrAvailable, false); + assert.equal(nexusGuildMovementAvailable(viewGame(result.afterSecondShipment, f.owner)), true); + settled(f, result, mode === 'twoGroups' ? 3 : 2); + } +}); + +void test('genuine completed two-group flight before Cunning preserves its earned credit and one unused Hajr move', () => { + for (const advanced of [false, true]) { + const f = createGuildCunningOrnithopterFixture({ advanced }); + const result = completeGuildCunningOrnithopter(f, { position: 'before' }); + assert.equal(result.beforeCunning.ornithopter ?? null, null); + assert.equal(player(result.beforeCunning).moved, 2); + assert.equal(player(result.beforeCunning).ornithopterExtraMoves, 1); + const receipt = record(result.allowed).receipt; + assert.equal(receipt.version, 3); + if (receipt.version === 3) assert.deepEqual(receipt.before, + { shipped: true, moved: 2, hajrPlays: 1, ornithopterExtraMoves: 1 }); + assert.equal(viewGame(result.afterSecondShipment, f.owner).nexusGuildCunning!.active!.movesLeft, 1); + assert.equal(result.moves.at(-1)!.kind, 'post-cunning-hajr'); + assert.equal(result.moves.at(-1)!.step.action.movementCard, undefined); + assert.equal(result.moves.at(-1)!.step.action.ornithopterEvent, undefined); + assert.equal(record(result.completed).movesAfter, 1); + settled(f, result, 3); + } +}); + +void test('genuine partial flights end before or after Cunning without earning an abandoned second-group credit', () => { + const f = createGuildCunningOrnithopterFixture(); + for (const early of ['beforeCunning', 'afterCunning'] as const) { + const result = completeGuildCunningOrnithopter(f, { early }); + assert.equal(result.firstFlightGroup.ornithopter!.completed, 1); + assert.equal(result.firstFlightGroup.ornithopter!.mode, 'twoGroups'); + assert.equal(result.finishedFlight, null); + assert.equal(player(result.game).ornithopterExtraMoves ?? 0, 0); + assert.equal(result.moves.length, 2); + if (early === 'beforeCunning') { + assert.equal(result.beforeCunning.ornithopter!.completed, 1); + assert.equal(result.allowed.ornithopter ?? null, null); + const receipt = record(result.allowed).receipt; + if (receipt.version === 3) assert.equal(receipt.before.ornithopterExtraMoves, 0); + assert.equal(result.moves.at(-1)!.kind, 'post-cunning-hajr'); + } else assert.equal(result.completed.ornithopter!.completed, 1); + settled(f, result, 2); + } +}); + +void test('all four minimal policies retain legal Hajr-backed card groups during original Guild Cunning', () => { + const f = createGuildCunningOrnithopterFixture(); + const result = completeGuildCunningOrnithopter(f); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + for (const game of [result.afterSecondShipment, result.firstFlightGroup]) { + const view = viewGame(game, f.owner); + view.players.find(player => player.id === f.owner)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'move' && + (game.ornithopter ? action.ornithopterEvent === view.ornithopter!.active!.event : action.movementCard === f.card)); + assert.ok(action, `${difficulty} retains a legal physical-card continuation inside the native Cunning grant`); + const next = actGuildCunningOrnithopter(structuredClone(game), f.owner, action); + assert.ok(player(next).moved === player(game).moved + 1 || next.response || next.decision); + } + } +}); + +void test('the genuine held physical card cannot restore ordinary movement or a Hajr grant already spent before Cunning', () => { + const f = createGuildCunningOrnithopterFixture(); + const sample = completeGuildCunningOrnithopter(f); + for (const spentHajr of [false, true]) { + let game = structuredClone(f.game); + const act = (action: Parameters[2]) => { + game = actGuildCunningOrnithopter(game, f.owner, action); + game = advanceGuildCunningOrnithopter(game, guildCunningOrnithopterClean); + }; + if (spentHajr) act({type:'card',card:f.hajr}); + act(guildCunningOrnithopterMove(game, f.source)); + if (spentHajr) act(guildCunningOrnithopterMove(game, f.source)); + const offer = viewGame(game,f.owner).nexusGuildCunning!.offer!; + const declare = nexusGuildCunningAction(viewGame(game,f.owner), offer.event); assert.ok(declare); + act(declare); + act(sample.secondShipment.action); + assert.equal(own(game).movesAllowed, player(game).moved); + assert.equal(nexusGuildMovementAvailable(viewGame(game,f.owner)), false); + assert.match(viewGame(game,f.owner).ornithopter!.blocked!, /unused ordinary or Hajr/); + const illegal = guildCunningOrnithopterMove(game, sample.secondSource, {mode:'range3',range:3}); + const before = structuredClone(game); + assert.throws(() => actGuildCunningOrnithopter(game,f.owner,illegal), /movement|move/i); + assert.deepEqual(game,before); + assert.ok(player(game).hand.some(card => card.id === f.card)); + } +}); diff --git a/tests/nexus-guild-cunning-ornithopter.test.ts b/tests/nexus-guild-cunning-ornithopter.test.ts new file mode 100644 index 00000000..ed762edf --- /dev/null +++ b/tests/nexus-guild-cunning-ornithopter.test.ts @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createNexusGuildCunning, + nexusGuildCunningMoves, + validateNexusGuildCunning, + type NexusGuildCunningMovementBefore, + type NexusGuildCunningMovementReceipt, + type NexusGuildCunningReceipt, +} from '../game/nexus-guild-cunning'; + +const context = { + turn: 3, + phase: 5, + active: 'g', + players: [ + { id: 'a', faction: 'atreides' as const }, + { id: 'g', faction: 'guild' as const }, + { id: 'f', faction: 'fremen' as const }, + ], +}; +const clone = (value: T): T => JSON.parse(JSON.stringify(value)); +const movement = (moved: number, hajrPlays: number, ornithopterExtraMoves: number) => + createNexusGuildCunning(context, 'g', { + shipped: true, moved, hajrPlays, ornithopterExtraMoves, + }); + +// These pure inputs represent accepted physical movement/card-play counts. +// Original acquisition, grant replacement and an active flight's unfinished +// rights remain engine integration evidence, not claims made by these quotes. +void test('v3 Guild Cunning records completed preflight credits without losing an unused Hajr grant', () => { + const before = Object.freeze({ + shipped: true, moved: 3, hajrPlays: 2, ornithopterExtraMoves: 1, + }); + const receipt: NexusGuildCunningMovementReceipt = createNexusGuildCunning(context, 'g', before); + assert.equal(receipt.version, 3); + assert.deepEqual(receipt.before, before); + assert.notEqual(receipt.before, before); + assert.deepEqual(Object.keys(receipt.before).sort(), [ + 'hajrPlays', 'moved', 'ornithopterExtraMoves', 'shipped', + ]); + assert.equal(receipt.signature, JSON.stringify([ + 'nexusGuildCunningReceipt', 3, '["nexusGuildCunning",3,"g"]', + 'g', 3, 5, 'cunning', [['a', 'atreides'], ['f', 'fremen'], ['g', 'guild']], + [true, 3, 2, 1], + ])); + validateNexusGuildCunning(context, clone(receipt)); + assert.equal(nexusGuildCunningMoves(receipt, 2, 0, 1), 1, + 'Two first-flight groups plus one later move consumed three physical moves, not both Hajr grants'); + assert.equal(nexusGuildCunningMoves(receipt, 2, 1, 1), 0); + assert.throws(() => nexusGuildCunningMoves(receipt, 2, 2, 1)); + assert.deepEqual(receipt.before, before); +}); + +void test('completed first-flight groups consume their earned extra credit before Cunning', () => { + const oneHajrUnused = movement(2, 1, 1); + assert.equal(nexusGuildCunningMoves(oneHajrUnused, 1, 0, 1), 1); + const oneHajrSpent = movement(3, 1, 1); + assert.equal(nexusGuildCunningMoves(oneHajrSpent, 1, 0, 1), 0); + assert.throws(() => nexusGuildCunningMoves(oneHajrSpent, 1, 1, 1)); + assert.equal(nexusGuildCunningMoves(oneHajrSpent, 2, 0, 1), 1, + 'A genuinely new Hajr play can still earn one new grant'); + const bothHajrSpent = movement(4, 2, 1); + assert.equal(nexusGuildCunningMoves(bothHajrSpent, 2, 0, 1), 0); +}); + +void test('post-shipment Hajr and a completed two-group card use retain the actual physical movement count', () => { + const receipt = movement(1, 0, 0); + assert.equal(nexusGuildCunningMoves(receipt, 0), 0); + assert.equal(nexusGuildCunningMoves(receipt, 1), 1, + 'The actual Hajr play earns one replacement allowance'); + assert.equal(nexusGuildCunningMoves(receipt, 1, 1), 0, + 'A range-three move or an unfinished flight has no completed extra credit'); + assert.equal(nexusGuildCunningMoves(receipt, 1, 2, 1), 0, + 'The completed two-group use records one extra credit and both physical moves'); + assert.throws(() => nexusGuildCunningMoves(receipt, 1, 3, 1)); + assert.equal(nexusGuildCunningMoves(receipt, 2, 2, 1), 1, + 'A further actual Hajr adds one grant without replaying either card group'); +}); + +void test('Cunning never restores an ordinary move or an already spent Hajr or card credit', () => { + for (const moved of [0, 1]) { + const receipt = movement(moved, 0, 0); + assert.equal(nexusGuildCunningMoves(receipt, false), 0); + assert.throws(() => nexusGuildCunningMoves(receipt, false, 1)); + } + const completedFlightWithoutHajr = movement(2, 0, 1); + assert.equal(nexusGuildCunningMoves(completedFlightWithoutHajr, 0, 0, 1), 0); + assert.throws(() => nexusGuildCunningMoves(completedFlightWithoutHajr, 0, 1, 1)); + const consumedHajr = movement(2, 1, 0); + assert.equal(nexusGuildCunningMoves(consumedHajr, true), 0); + assert.throws(() => nexusGuildCunningMoves(consumedHajr, true, 1)); +}); + +void test('v1 and v2 Guild Cunning receipt bytes and default movement quotes remain unchanged', () => { + const common = { + event: '["nexusGuildCunning",3,"g"]', owner: 'g', turn: 3, + phase: 5, mode: 'cunning', + roster: [ + { id: 'a', faction: 'atreides' }, + { id: 'f', faction: 'fremen' }, + { id: 'g', faction: 'guild' }, + ], + }; + const legacy = createNexusGuildCunning(context, 'g', { + shipped: true, moved: 1, hajrUsed: true, + }); + const counted = createNexusGuildCunning(context, 'g', { + shipped: true, moved: 1, hajrPlays: 2, + }); + assert.equal(JSON.stringify(legacy), JSON.stringify({ + version: 1, ...common, + before: { shipped: true, moved: 1, hajrUsed: true }, + signature: '["nexusGuildCunningReceipt",1,"[\\"nexusGuildCunning\\",3,\\"g\\"]","g",3,5,"cunning",[["a","atreides"],["f","fremen"],["g","guild"]],[true,1,true]]', + })); + assert.equal(JSON.stringify(counted), JSON.stringify({ + version: 2, ...common, + before: { shipped: true, moved: 1, hajrPlays: 2 }, + signature: '["nexusGuildCunningReceipt",2,"[\\"nexusGuildCunning\\",3,\\"g\\"]","g",3,5,"cunning",[["a","atreides"],["f","fremen"],["g","guild"]],[true,1,2]]', + })); + validateNexusGuildCunning(context, clone(legacy)); + validateNexusGuildCunning(context, clone(counted)); + assert.equal(nexusGuildCunningMoves(legacy, true), 1); + assert.equal(nexusGuildCunningMoves(legacy, true, 1), 0); + assert.equal(nexusGuildCunningMoves(counted, 2), 2); + assert.equal(nexusGuildCunningMoves(counted, 2, 2), 0); +}); + +void test('older signed receipts can count genuinely new Ornithopter credits without changing their original snapshots', () => { + const receipts = [ + createNexusGuildCunning(context, 'g', { shipped: true, moved: 1, hajrUsed: false }), + createNexusGuildCunning(context, 'g', { shipped: true, moved: 1, hajrPlays: 0 }), + ]; + for (const receipt of receipts) { + const original = JSON.stringify(receipt); + assert.equal(nexusGuildCunningMoves(receipt, 1, 2, 1), 0); + assert.equal(nexusGuildCunningMoves(receipt, 2, 2, 1), 1); + assert.equal(JSON.stringify(receipt), original); + validateNexusGuildCunning(context, receipt); + } +}); + +void test('v3 validates exact original movement shapes and safe combined ceilings', () => { + const before: NexusGuildCunningMovementBefore = { + shipped: true, moved: 2, hajrPlays: 1, ornithopterExtraMoves: 1, + }; + for (const bad of [ + { ...before, shipped: 1 }, + { ...before, moved: -1 }, + { ...before, moved: 0.5 }, + { ...before, moved: 4 }, + { ...before, moved: Number.MAX_SAFE_INTEGER + 1 }, + ...[-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER, undefined, true].map( + ornithopterExtraMoves => ({ ...before, ornithopterExtraMoves })), + { ...before, hajrPlays: -1 }, + { ...before, hajrPlays: Number.MAX_SAFE_INTEGER - 1 }, + { ...before, hajrUsed: true }, + { ...before, privateFlight: {} }, + ]) assert.throws(() => createNexusGuildCunning(context, 'g', bad as NexusGuildCunningMovementBefore)); + const maximum = movement(1, Number.MAX_SAFE_INTEGER - 2, 1); + assert.equal(nexusGuildCunningMoves(maximum, Number.MAX_SAFE_INTEGER - 2, 0, 1), Number.MAX_SAFE_INTEGER - 1); + assert.equal(nexusGuildCunningMoves(maximum, Number.MAX_SAFE_INTEGER - 2, Number.MAX_SAFE_INTEGER - 1, 1), 0); +}); + +void test('current movement counts cannot lose original grants, overflow, or overconsume the signed allowance', () => { + const receipt = movement(2, 1, 1); + for (const currentExtra of [-1, 0, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER, true, null]) + assert.throws(() => nexusGuildCunningMoves(receipt, 1, 0, currentExtra as number)); + for (const currentHajr of [0, false, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER - 1]) + assert.throws(() => nexusGuildCunningMoves(receipt, currentHajr, 0, 1)); + for (const consumed of [-1, 0.5, NaN, Infinity, 2]) + assert.throws(() => nexusGuildCunningMoves(receipt, 1, consumed, 1)); + assert.throws(() => nexusGuildCunningMoves(receipt, 1), + 'A v3 receipt with prior extra credits requires their actual current count'); + assert.equal(nexusGuildCunningMoves(receipt, true, 1, 1), 0); +}); + +void test('v3 signatures bind original card credits and cannot be relabelled as a legacy format', () => { + const receipt = movement(2, 1, 1); + const mutations: ((value: NexusGuildCunningMovementReceipt) => void)[] = [ + value => { value.before.ornithopterExtraMoves = 2; }, + value => { value.before.hajrPlays = 2; }, + value => { value.before.moved = 1; }, + value => { value.before.shipped = false; }, + value => { value.signature = ''; }, + ]; + for (const change of mutations) { + const invalid = clone(receipt); change(invalid); + assert.throws(() => validateNexusGuildCunning(context, invalid)); + } + for (const version of [1, 2, 4]) + assert.throws(() => validateNexusGuildCunning(context, { + ...receipt, version, + } as NexusGuildCunningReceipt)); + for (const version of [1, 2]) { + const original = version === 1 + ? createNexusGuildCunning(context, 'g', { shipped: true, moved: 1, hajrUsed: true }) + : createNexusGuildCunning(context, 'g', { shipped: true, moved: 1, hajrPlays: 1 }); + assert.throws(() => validateNexusGuildCunning(context, { + ...original, before: { ...original.before, ornithopterExtraMoves: undefined }, + } as NexusGuildCunningReceipt)); + } +});