From 7b9938219d7f3e0e3b9f82ae752ad368b26fe901 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:06:16 +1100 Subject: [PATCH] Resume native Basic arrivals after Ornithopter recovery Allow the completed flight discard in the existing bare Basic E3 profile. Keep physical movement and its original arrival, then handle distinct Ambassador or Terror fresh groups without replaying effects or Semuta cost. --- docs/CURRENT_STATUS.md | 21 ++ docs/IMPLEMENTATION_STATUS.md | 30 +++ docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md | 11 + docs/RULE_DECISIONS.md | 2 +- docs/SEMUTA_SOURCE_UPDATE_20260907.md | 85 ++++++- game/engine.ts | 3 +- game/reference.ts | 15 +- .../fixture-semuta-ornithopter-ambassador.ts | 140 +++++++++++ tests/fixture-semuta-ornithopter-terror.ts | 228 ++++++++++++++++++ tests/semuta-ornithopter-ambassador.test.ts | 134 ++++++++++ ...emuta-ornithopter-native-controls.test.tsx | 163 +++++++++++++ tests/semuta-ornithopter-terror.test.ts | 151 ++++++++++++ 12 files changed, 975 insertions(+), 8 deletions(-) create mode 100644 tests/fixture-semuta-ornithopter-ambassador.ts create mode 100644 tests/fixture-semuta-ornithopter-terror.ts create mode 100644 tests/semuta-ornithopter-ambassador.test.ts create mode 100644 tests/semuta-ornithopter-native-controls.test.tsx create mode 100644 tests/semuta-ornithopter-terror.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 48cfc9e2..2fd62ec5 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,27 @@ open mode gates. ## Current checkpoint and work +**Native Basic Ornithopter recovery and arrival — 24 October. Prototyped / Partial.** +Completed range-three/final-two-group flights now offer Semuta in the existing +bare Basic E3 profile. Retire that first fresh group before the original native +arrival; CHOAM/Ix Ambassador or Terror then has its own distinct discard event. +Physical movement, counters, credit, replacement draw and gift never replay. +One Semuta can recover the flight or a later native card, with existing +full-hand exchange and persistent controls. + +Disjoint Ambassador/Terror sources, controls, bounded review and owner +integration pass **10 files135/135**, types/lint/build, sixteen actual gameplay/ +control/table-rendering journeys and eight existing-profile continuations +(28 accepted proposals). Genuine paid Orni/placement/shipments supply Ambassador +prefix56 plus1/2 branch actions and Terror70/71 actions. Semuta/overflow/full-hand +prior-card acquisitions are explicitly conserved staging. Source feedback +repaired setup-vs-phase ordering, live escrow inventory and legal later Storm +dials; a discriminated offer guard fixes the first type-check failure. +[Source, proof and limits](SEMUTA_SOURCE_UPDATE_20260907.md#native-ornithopter-movement-and-arrival--24-october-2026). +Overlap, native Advanced E3, optional profiles, complete modes, comprehensive/ +browser/deployed acceptance and full AI refinement remain unfinished. +No saved-room/D1/reset/restart/deployment operation. + **Semuta and native Ghola income — 24 October. Prototyped / Partial.** Semuta now recovers a completed Ghola while its original Tleilaxu one-spice income is pending. One shared source predicate retains exact native ownership, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 27826b0b..c9216a2a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 24 October 2026 — Native Basic Ornithopter recovery and arrival + +Admit the completed movement-card fresh group in the existing bare Basic E3 +Semuta profile. Keep the actual moved group/counters and retire that event +before original movement-caused arrival. Clean CHOAM/Ix Ambassador and +Sabotage/Robbery overflow then retain distinct fresh groups, original payout, +one replacement/draw and native gift. The single physical Semuta cannot be +spent twice. Existing signatures and serialized fields remain unchanged; +overlap, native Advanced E3 and optional-profile guards remain. + +Disjoint source/test lanes, actual control callbacks, bounded continuation +review and owner integration pass10 files135/135, types/lint/build and sixteen +gameplay/GameTable SSR programmes across both modes and both recovery points. +Eight existing-profile later-native continuations accept28 proposals without +strategy changes. Ambassador prefix56 plus1/2 real branch actions and Terror +70/71 actions retain actual setup, paid cache Orni, placements and shipments. +Semuta/overflow/full-hand acquisitions are explicitly conserved staging. +BG copy and empty CHOAM grants have separate source/control proof. + +The pre-change range-three CHOAM case reproduces the missing first offer. +Source feedback corrects phase handling before traitor setup, the final native +Moritani setup window, legal next-turn1+1 dials and physical escrow inventory. +The first frozen rule union passes but type checking finds an offer/select +narrowing issue in a fixture; its source-qualified pass guard is repaired, +then final frozen proof passes. +[Exact source/evidence and remaining gates](SEMUTA_SOURCE_UPDATE_20260907.md#native-ornithopter-movement-and-arrival--24-october-2026). +No complete-mode/combination or browser/deployed certificate, full AI tuning, +comprehensive assurance or saved-room/D1/reset/restart/deployment operation. + + ## 24 October 2026 — Semuta before native Ghola income A completed Ghola now offers its actual used card while the original Tleilaxu diff --git a/docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md b/docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md index 7c0bcf72..86043c11 100644 --- a/docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md +++ b/docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md @@ -2,6 +2,17 @@ 7 September 2026. Retiring the played Ornithopter now preserves the exact remaining arrival or turn-ending step. This continues the shared discard infrastructure without enabling Semuta or changing any unresolved movement rule or expansion start gate. +## Native Basic Semuta arrival follow-up — 24 October 2026 + +The [current native profile](SEMUTA_SOURCE_UPDATE_20260907.md#native-ornithopter-movement-and-arrival--24-october-2026) +now admits the completed-card fresh group in bare Basic E3 tables. Original +movement, escrow retirement and the existing after-disposal arrival remain. +Claim/Continue retires that group before the actual movement-caused entry. +Clean CHOAM/Ix Ambassador or Terror discards have their own later event and +continuation; the single Semuta cannot be spent twice. Existing overlap, +Advanced E3 and optional-profile boundaries remain. Older checkpoint counts +below are dated continuation evidence, not a current complete-mode certificate. + ## 11 October 2026 — Semuta before native Intrusion The explicit Semuta development envelope now pauses the final public Ornithopter diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index e2504c6d..cd3eb73e 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -908,7 +908,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) | | Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) | | Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible/current-round rule with one future qualifying physical ground movement before the respondent's actual own turn ends. Classic Basic phases0–4 include owned Charity/ordinary Revival and a currently leading ordinary invoice, reopening Ready and preserving joint shipment predicates; current-phase guidance only. Actual cash/held Karama and a distinct later shipment-rate card retain their costs; mandatory Storm losses release impossibility and stale movement allowances reset. Declared native early entries also price exact Richese own/donor commitments, lock the full submitted-ally Silent pledge, avoid double-debiting paid sales and retain current event-bearing zero/pass controls. Foreign invoice outcomes precede release. Non-CHOAM preopening Charity quotes its fixed entitlement only; owned normal revival projects shared physical accounting while native counters/income stay unresolved. CHOAM own unsettled opening remains outside the forecast. Active or unused waiting turns may answer; forecast current board/resources without foreign outcomes, and defer ordinary execution until active. Other players' moves/ends cannot settle this ledger. Actual paid Box selection remains actionable while waiting and may choose any entitled card preserving the route. Deferred gift/accompaniment costs retain owner preflight; native pending moves defer only their own moving player. Ordinary reserve preparation retains prices, typed withdrawals and pledged splits; joint answers and exact pending declarations remain. Yes survives nonmatches while possible; No forbids later matches through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls and compatible answers after opposing loss. Held Kulon, physical Ghola force returns and known-discard Box retain real counters, unused affordable shipment, fee/event/full-hand policy and normal revival allowances; possible Tleilaxu income is not forecast cash. Legitimate Box recovery can replay the single Ghola. No unknown inspection or erased historical memory. Shipment/HMS/Ambassador relocation and No-Field markers do not fulfill physical ground movement. Predicate/release/forecast policy is software composition, not a movement-specific publisher FAQ. Later rounds, native transport preparation, wider/module combinations and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) | -| Semuta / native Basic entries | Fresh bare public Basic Richese previews initialize the existing protocol before genuine setup with selected native E1/E2/E3 families; no optional/Advanced inheritance or old-game retrofit. Clean source-witnessed shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: keep CHOAM's committed credit and draw the Ixian replacement only after the reaction. This composes printed component rules, not a new timing FAQ. Existing Basic Terror overflow retains the neutral all-seat response, committed-only face access, original victim/draw, removed token, payment, forces and optional gift. Nested parents, completed movement-card provenance and wider module/full-game coverage remain unfinished implementation boundaries, not printed Semuta prohibitions. | [Fresh Basic and Ambassador scope](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026), [native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md) | +| Semuta / native Basic entries | Fresh bare public Basic Richese previews initialize the existing protocol before genuine setup with selected native E1/E2/E3 families; no optional/Advanced inheritance or old-game retrofit. Clean source-witnessed shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: keep CHOAM's committed credit and draw the Ixian replacement only after the reaction. This composes printed component rules, not a new timing FAQ. Existing Basic Terror overflow retains the neutral all-seat response, committed-only face access, original victim/draw, removed token, payment, forces and optional gift. Completed Ornithopter admits its fresh group in the bare Basic profile, then the original movement-caused clean single-token Ambassador/Terror suffix and its distinct discard. Original physical movement/counters, CHOAM credit, Ix replacement and Terror draw/gift stay once-only. Overlap, other nested parents and wider module/full-game coverage remain unfinished implementation boundaries, not printed Semuta prohibitions. | [Fresh Basic and Ambassador scope](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026), [native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md) | Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question truth: Yes to AND requires all, Yes to OR requires one, and No negates the whole. diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index dd49ba8e..a3003e33 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,87 @@ 7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits. +## Native Ornithopter movement and arrival — 24 October 2026 + +**Prototyped / Partial.** Fresh bare Basic Richese tables with selected native +E3 factions now offer Semuta after a completed Ornithopter movement, including +range-three and the final two-group move. This removes the blanket Ecaz/Moritani +seat exclusion for that existing public Basic profile. Original classic/ +non-E3 permissions remain; native Advanced E3 and optional combined profiles +retain their preceding guards. Existing games do not gain a Semuta setup marker. + +The physical group and movement counters commit first. The finished card is +the first fresh group; claim or every-seat Continue retires it before the +original movement arrival runs. Taking the card does not move the forces again, +grant another movement or end the original player's turn. The saved completed +move and existing native-token signature remain unchanged; no new serialized +field, receipt or arrival rule is introduced. + +The original suffix opens a clean single-token Ambassador or Terror entry with +its actual movement cause. CHOAM private discards retain their already-paid +three spice per card; Ixian discard pauses before its one replacement draw. +Sabotage/Robbery overflow keep their native draw/discard and gift continuations. +Each is a later, separate fresh event. A holder who spends the single physical +Semuta on Ornithopter cannot spend it again on that later group. Passing the +first group can preserve it for an eligible later card. + +This composes existing printed movement/discard mechanics and the current +Semuta protocol, not a new publisher timing ruling. Ambassador/Terror overlap +keeps its existing preflight/order: Ambassador discard admission remains guarded +while an overlap is active. Other nested arrivals, native Advanced E3, +optional combinations, complete modes and final acceptance remain unfinished. + +### Native Ornithopter source and proof + +The Ambassador source reuses only the original public Basic Ecaz/CHOAM/Richese/ +Guild prefix before Guild shipment: paid original cache Orni1, Ecaz's actual +ordinary purchase1 and token placement1. Its unused constructor normal-move/ +trigger branch is neither replayed nor counted. Prefix56 actions plus1 real +shipment (range-three) or2 branch actions (shipment2 and first same-territory +sector move) reach the caller-owned final move. The first two-group counter +really moves Ridge Flat17→18; the remaining original counter enters Sietch17. +Only the original cache Semuta is transferred to Richese as labelled acquisition. + +The Terror source really starts Basic Moritani/Richese/Guild, chooses native +Oh Gap setup, buys original Orni1 in turn1, places the actual hidden Terror at +Mentat, uses legal first zero/later1+1 dials and pays turn2 shipment. Its70/71 +actions reach range-three/final-two-group movement into Carthag11; two-group +mode first moves another counter to empty Arrakeen10. Robbery's four-card hand +and optional claimant full hand use labelled conserved ordinary-card +acquisitions; no force, clock, token or pending receipt is installed. + +The first new range-three CHOAM regression fails before admission, then passes. +Twelve Ambassador cases, eight Terror cases and two actual-state control cases +cover both modes, flight/later-card recovery, BG copy, empty CHOAM choice, +original private selection, full hands, single spend, stale first-event +rejection and exact original continuation. CHOAM credits6 once; Ix draws one +replacement only after its fresh group; Sabotage gifts one original card or +declines; Robbery draws one actual top card before its overflow discard. +Native token/force/card custody stays physical and movement never repeats. + +The final affected **10-file135/135** union, npm run check:quick and production +build pass. Sixteen gameplay/GameTable SSR programmes exercise both modes, +both recovery points and all four native effect families, through explicit +movement end. Eight existing-profile later-discard continuations accept28 +proposals, all making a legal claim. Actual Continue/Commit/Select callbacks +retain their own events; busy handlers submit nothing. No strategy tuning or +strength calibration occurs. Source feedback corrected setup/phase adapters, +live flight inventory and later legal Storm dials. The first frozen union +passed rules but failed a fixture's offer/select type narrowing; the repaired +final source passes all checks. + +Private artifacts: /home/quintine/.local/share/dune/checkpoints/20261024-semuta-native-ornithopter/, +final frozen-integrated-2/report.json, runtime-smoke.json, private HTML/logs, +verified-files.json and documentation-overlay.json. Before final prose: +base047011e2438e06e93dc68d423d56135cb883846d, +source tree96867bfb66adb8a889ca001990067f35f7f9586241b6a3b7a007bc26c6398bfc, +2670 tracked/nonignored files unchanged throughout verification. Only four +checkpoint-prose/reference overlays follow; local links/evidence and reference +syntax/keys are checked separately. Framework aliases/CSS stub provide rendering +smoke, not browser/visual/deployed acceptance. Overlap, native Advanced E3, +optional combinations, complete modes and comprehensive assurance remain open. +No saved-room/D1/reset/restart/deployment operation occurs. + ## Ghola and conditional native income — 24 October 2026 **Prototyped / Partial.** A completed ordinary Ghola can offer its used physical @@ -91,8 +172,8 @@ now offer the same neutral reaction. A fresh entry retains its original arrival cause. Only phase-five shipment/movement entries with no active nested arrival, worm, relocation, transport, revival or purchase parent enter this scope. Older frames without that witness retain their original immediate continuation. -Completed movement-card provenance and all other nested combinations remain -unfinished interaction coverage; cause admission does not certify them. +Completed Ornithopter movement now has the native follow-up above. Other +nested combinations remain unfinished; cause admission does not certify them. The existing [discard inventory](SEMUTA_DISCARD_CONTINUATIONS.md) already defines the order: CHOAM's selected private cards form one simultaneous batch **after** diff --git a/game/engine.ts b/game/engine.ts index a3ab5cc9..4d9ef0bb 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -7621,7 +7621,8 @@ function semutaOfferSupported( !(continuation.kind === 'ornithopterDiscard' && (continuation.source === 'end' || continuation.source === 'cunning' || (continuation.source === 'move' && - !g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') && + (!g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') || + bareBasicSemutaProfile(g)) && !g.pendingArrivalOverlap))) && !(continuation.kind === 'truthtranceDiscard' && (continuation.consumed.source === undefined || continuation.consumed.source === 'shrine') && diff --git a/game/reference.ts b/game/reference.ts index a67161f9..fc383608 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -930,6 +930,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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. 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.', + 'In supported Basic Ecaz/Moritani tables with Richese, Semuta may recover the completed movement card before the original arrival reaction. The forces and counters stay moved; the original player retains their actual turn. Later Ambassador or Terror discards form separate fresh groups and never repeat the movement.', ], related: [ 'card-richese-ornithopter', @@ -967,7 +968,7 @@ export const RULE_TOPICS: RuleTopic[] = [ status: 'Partial', detail: '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: [ + evidence: ['tests/semuta-ornithopter-ambassador.test.ts', 'tests/semuta-ornithopter-terror.test.ts', 'tests/semuta-ornithopter-native-controls.test.tsx', 'game/ornithopter.ts', 'game/nexus-guild-cunning.ts', 'tests/nexus-guild-cunning-ornithopter.test.ts', @@ -1301,6 +1302,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Original native Homeworld battle-reward cancellation costs also offer printed Karama: Advanced Harkonnen capture, native CHOAM Auditor and quoted force-payment income. Recovery preserves the accepted battle, paid support, leader death and original casualty/cleanup order, then completes the same denial. It cannot sample or transfer a captive, pay/inspect an audit, refund support or credit canceled income. An audit cancellation leaves any earlier allowed force-payment income intact. Existing exact roster, private-custody and source guards remain.', 'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.', 'A completed Ghola can also be claimed while its native Tleilaxu one-spice income is waiting. The piece has already returned. Claim or Continue restores that same allow-or-cancel response without another revival or advance payment; normal revival allowances stay unchanged. This includes Tleilaxu using its own Ghola. Other pending effects retain their existing timing.', + 'A finished Ornithopter in a fresh Basic Ecaz/Moritani table with Richese offers its own fresh discard before the original arrival. Claim or Continue preserves the moved group and movement counters. A clean Ambassador or Terror effect then has its own later discard event, with its original credit, replacement draw or gift. The single Semuta cannot be spent on both groups; overlapping entries keep their separate timing limits.', 'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.', 'A clean Distrans transfer during battle also offers only the used Distrans. The private card has already reached its recipient; claim or Continue cannot repeat the gift or change a sealed plan. A recipient holding Semuta may claim the used Distrans and keep the already-transferred card.', 'Reactive Baliset and La La La also offer their used card after the accepted cost. The original uncommitted move or free-revival request is already stopped: claim or Continue installs the original restriction without moving forces, consuming movement, reviving or charging the denied request. Another legal move or paid revival remains available. Movement-card, Ambassador, Nexus and other competing parents remain separate.', @@ -1316,7 +1318,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - "Completed Ghola now offers Semuta before its exact conditional native one-spice income, including self-income. Claim/pass preserves returned pieces and normal quotas, then restores the original allow/cancel response once. Other parent guards remain. The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner. Printed native special costs now resume one committed native effect through effect/complete/cashout stages. Original Emperor post-revival disposal is preserved; CHOAM selected cards are a later private simultaneous batch after one credit. Existing native once-use, joint ordering and typed parent remain. Native BG physical Worthless cost now precedes the single original conversion emitter; the separately printed cancellation cost retains that pending use and still denies it. All four use bindings, incoming reservations and older passes remain. Allowed converted prices carry claimed-cost provenance through actual later rediscard/refund. Fresh private Discovery composition now connects both physical Shrine-role costs without changing original effects or printed identity. Fresh bare public Basic starts now initialize Semuta with selected native families. Clean CHOAM/Ixian Ambassador private batches, including BG copies, retain the original entry, already-earned credit and one later replacement draw. Older games and optional/Advanced scopes remain separate; nested originating-card coverage remains unfinished.", + "Completed native Basic Ornithopter now offers its fresh group before the original movement-caused arrival. Clean Ambassador/Terror discards then use distinct events and original credit/draw/gift; one Semuta cannot pay twice. Overlap and other profile guards remain. Completed Ghola now offers Semuta before its exact conditional native one-spice income, including self-income. Claim/pass preserves returned pieces and normal quotas, then restores the original allow/cancel response once. Other parent guards remain. The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner. Printed native special costs now resume one committed native effect through effect/complete/cashout stages. Original Emperor post-revival disposal is preserved; CHOAM selected cards are a later private simultaneous batch after one credit. Existing native once-use, joint ordering and typed parent remain. Native BG physical Worthless cost now precedes the single original conversion emitter; the separately printed cancellation cost retains that pending use and still denies it. All four use bindings, incoming reservations and older passes remain. Allowed converted prices carry claimed-cost provenance through actual later rediscard/refund. Fresh private Discovery composition now connects both physical Shrine-role costs without changing original effects or printed identity. Fresh bare public Basic starts now initialize Semuta with selected native families. Clean CHOAM/Ixian Ambassador private batches, including BG copies, retain the original entry, already-earned credit and one later replacement draw. Older games and optional/Advanced scopes remain separate; nested originating-card coverage remains unfinished.", }, { area: 'Player controls' as const, @@ -1328,7 +1330,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'AI' as const, status: 'Partial' as const, detail: - "Eight conditional Ghola-income continuations accept32 legal proposals across normal/full hands and all four existing profiles; all recover the card and allow the real income. No strategy tuning. All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration. All four existing profiles recover the original Guild stop cost via legal private actions, and the native prevention then executes once. No new preference, tuning or calibration. For each native BG cost and printed counter-cost family, all four existing profiles recover only the current fresh card through their private legal action. Native response allowance/denial remains separate; subsequent owner actions exercise the real retained source. No tuning or calibration. Thirty-two controlled legal-policy probes cover both physical Shrine-role events in both modes; no calibration. Four existing profiles also complete actual public Basic BG-copy CHOAM Ambassador reactions through twenty legal proposals; no new strategy or calibration.", + "Eight native Ornithopter/Ambassador-or-Terror later-discard continuations accept28 legal proposals; no new strategy or calibration. Earlier conditional Ghola-income continuations accept32 legal proposals across normal/full hands and all four existing profiles; all recover the card and allow the real income. No strategy tuning. All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration. All four existing profiles recover the original Guild stop cost via legal private actions, and the native prevention then executes once. No new preference, tuning or calibration. For each native BG cost and printed counter-cost family, all four existing profiles recover only the current fresh card through their private legal action. Native response allowance/denial remains separate; subsequent owner actions exercise the real retained source. No tuning or calibration. Thirty-two controlled legal-policy probes cover both physical Shrine-role events in both modes; no calibration. Four existing profiles also complete actual public Basic BG-copy CHOAM Ambassador reactions through twenty legal proposals; no new strategy or calibration.", }, { area: 'Documentation' as const, @@ -1340,7 +1342,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification' as const, status: 'Partial' as const, detail: - "Current conditional-Ghola proof passes10 files156/156, types/lint/build and eight actual-control/GameTable SSR journeys. Eight existing profiles accept32 proposals. Native160-action source plus explicitly conserved card staging covers claim/pass, full hands and allow/cancel; separate controlled self-income/leader cases retain physical identity and normal quotas. A private runner eligibility repair retains earlier passing checks by unchanged source identity. Full modes, wider parents, browser/deployed and comprehensive assurance remain open. The following counts are historical family evidence. Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. Earlier eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance. Earlier sixteen-file224/224 passes with13 new behavior cases. Twenty-six runtime/policy paths pass:8 original-action Guild paths including4 profiles,18 explicitly controlled native-effect paths. Four actual390px journeys pass (2 original/2 controlled). Existing income/refund consumers use actual neutral passes. No worker/D1/saved/deployed or complete-mode claim. Earlier nineteen-file238/238 passes with8 new cases. Twenty-six original-action runtime/policy paths and four actual original-action390px journeys pass. One controlled acquisition/phase probe uses a REAL subsequent CHOAM rediscard and Guild stop without force/wallet staging or duplicate refund. Separate2 controlled Kull parent probes and3 repaired focused cases pass. No worker/D1/saved/deployed certification. Eight new Shrine cases pass in the57-case affected union, with eight controlled runtime paths and two actual controlled390px journeys; no natural draw/entry, recovery or deployed acceptance. Fresh Basic/Ambassador evidence passes twelve-file142/142, types/lint/build, ten original-action handler/table-rendering programmes and four existing profiles with twenty legal proposals. Public setup, actual one-spice Semuta auction, card purchase, placement/shipment and two actual ordinary movement entries retain35 main plus10 cache. Only unopened positions and original scalar/packet lotteries are selected. Full-hand/own-card edges separately stage conserved card transfers. Stale battle acknowledgements/module-rejection expectations and fixture types were repaired; representative old failures reproduced against the previous engine. Framework aliases/CSS stub; no browser, visual, saved-room or deployed acceptance. Wider nested/provenance scope and complete modes remain open.", + "Current native-Ornithopter proof passes10 files135/135, types/lint/build and sixteen gameplay/GameTable SSR programmes. Eight existing-profile paths accept28 proposals. Real paid source prefixes and explicitly conserved prior-card acquisitions cover both modes, separate fresh groups, full hands and once-only native continuation. Source setup/dial/inventory and fixture type feedback is repaired before final frozen proof. Overlap, native Advanced E3, optional profiles and full-mode/browser/deployed/comprehensive gates remain open. Earlier conditional-Ghola proof passes10 files156/156, types/lint/build and eight actual-control/GameTable SSR journeys. Eight existing profiles accept32 proposals. Native160-action source plus explicitly conserved card staging covers claim/pass, full hands and allow/cancel; separate controlled self-income/leader cases retain physical identity and normal quotas. A private runner eligibility repair retains earlier passing checks by unchanged source identity. Full modes, wider parents, browser/deployed and comprehensive assurance remain open. The following counts are historical family evidence. Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. Earlier eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance. Earlier sixteen-file224/224 passes with13 new behavior cases. Twenty-six runtime/policy paths pass:8 original-action Guild paths including4 profiles,18 explicitly controlled native-effect paths. Four actual390px journeys pass (2 original/2 controlled). Existing income/refund consumers use actual neutral passes. No worker/D1/saved/deployed or complete-mode claim. Earlier nineteen-file238/238 passes with8 new cases. Twenty-six original-action runtime/policy paths and four actual original-action390px journeys pass. One controlled acquisition/phase probe uses a REAL subsequent CHOAM rediscard and Guild stop without force/wallet staging or duplicate refund. Separate2 controlled Kull parent probes and3 repaired focused cases pass. No worker/D1/saved/deployed certification. Eight new Shrine cases pass in the57-case affected union, with eight controlled runtime paths and two actual controlled390px journeys; no natural draw/entry, recovery or deployed acceptance. Fresh Basic/Ambassador evidence passes twelve-file142/142, types/lint/build, ten original-action handler/table-rendering programmes and four existing profiles with twenty legal proposals. Public setup, actual one-spice Semuta auction, card purchase, placement/shipment and two actual ordinary movement entries retain35 main plus10 cache. Only unopened positions and original scalar/packet lotteries are selected. Full-hand/own-card edges separately stage conserved card transfers. Stale battle acknowledgements/module-rejection expectations and fixture types were repaired; representative old failures reproduced against the previous engine. Framework aliases/CSS stub; no browser, visual, saved-room or deployed acceptance. Wider nested/provenance scope and complete modes remain open.", evidence: [ 'tests/basic-semuta-ambassador.test.ts', 'tests/fixture-basic-semuta-ambassador.ts', @@ -1348,6 +1350,11 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-recovery.test.ts', 'tests/bot-semuta.test.ts', 'tests/semuta-reaction-controls.test.tsx', + 'tests/fixture-semuta-ornithopter-ambassador.ts', + 'tests/fixture-semuta-ornithopter-terror.ts', + 'tests/semuta-ornithopter-ambassador.test.ts', + 'tests/semuta-ornithopter-terror.test.ts', + 'tests/semuta-ornithopter-native-controls.test.tsx', 'game/ordinary-discard-income.ts', 'tests/fixture-semuta-ghola-income.ts', 'tests/semuta-ghola-income.test.ts', diff --git a/tests/fixture-semuta-ornithopter-ambassador.ts b/tests/fixture-semuta-ornithopter-ambassador.ts new file mode 100644 index 00000000..f3c94057 --- /dev/null +++ b/tests/fixture-semuta-ornithopter-ambassador.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { + createBasicSemutaAmbassadorFixture, actBasicSemutaAmbassador, + advanceBasicSemutaAmbassador, basicSemutaAmbassadorClean, + basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer, + type BasicSemutaAmbassadorEffect, type BasicSemutaAmbassadorStep, +} from './fixture-basic-semuta-ambassador'; + +export const SEMUTA_AMBASSADOR_ORNITHOPTER = 'richese-ornithopter'; +export const actSemutaOrnithopterAmbassador = actBasicSemutaAmbassador; +export const semutaOrnithopterAmbassadorPlayer = basicSemutaAmbassadorPlayer; +export const semutaOrnithopterAmbassadorClean = basicSemutaAmbassadorClean; +export type SemutaOrnithopterAmbassadorStep = BasicSemutaAmbassadorStep; + +/** A live played flight is physical escrow, not a retired receipt's copy. */ +export function semutaOrnithopterAmbassadorInventory(game: Game): string[] { + const ids = basicSemutaAmbassadorInventory(game); + if (game.ornithopter) ids.push(game.ornithopter.card.id); + assert.equal(new Set(ids).size, ids.length, 'Every original physical card has one current location'); + return ids.sort(); +} + +/** Acknowledge only this current fresh group; never choose an Ambassador grant. */ +export function passSemutaOrnithopterAmbassador(source: Game): Game { + const pending = source.pendingTreacheryDiscard; + assert.equal(pending?.reaction?.stage, 'offer'); + const event = pending!.batch.event; + let game = source; + for (const p of source.players) { + const reaction = game.pendingTreacheryDiscard?.reaction; + if (!reaction || reaction.stage !== 'offer') break; + if (!reaction.passed.includes(p.id)) + game = actSemutaOrnithopterAmbassador(game, p.id, { type: 'semutaPass', event }); + } + return game; +} + +/** Sole-card recovery finishes natively; a real multi-card group needs selection. */ +export function claimSemutaOrnithopterAmbassador(source: Game, actor: string, card: string): Game { + const event = source.pendingTreacheryDiscard!.batch.event; + let game = actSemutaOrnithopterAmbassador(source, actor, { type: 'semutaCommit', event }); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = actSemutaOrnithopterAmbassador(game, actor, { type: 'semutaSelect', event, card }); + return game; +} + +/** Fork the actual pre-shipment source. The shared constructor's subsequent + * normal-move/trigger branch is not part of this branch's action history. */ +export function createSemutaOrnithopterAmbassadorFixture(options: { + effect: BasicSemutaAmbassadorEffect; + mode?: 'range3' | 'twoGroups'; + copied?: boolean; +}) { + const mode = options.mode ?? 'range3', effect = options.effect, copied = options.copied ?? false; + const original = createBasicSemutaAmbassadorFixture({ effect, copied, + arrival: 'movement', cacheCard: 'ornithopter' }); + const prefixEnd = original.actions.findIndex(step => step.actor === original.entrant && step.action.type === 'ship'); + assert.ok(prefixEnd >= 0, 'The inherited prefix ends before its first actual Guild shipment'); + const prefixActions = structuredClone(original.actions.slice(0, prefixEnd)); + const branchActions: SemutaOrnithopterAmbassadorStep[] = []; + let game = structuredClone(original.beforeArrival); + const originalInventory = semutaOrnithopterAmbassadorInventory(game); + const claimant = 'richese', entrant = original.entrant; + const index = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID); + assert.ok(index >= 0); + basicSemutaAmbassadorPlayer(game, claimant).hand.push(game.richeseCache!.splice(index, 1)[0]); + assert.ok(game.players.every(p => p.hand.length <= 4)); + const beforeShipment = structuredClone(game); + const shipment: SemutaOrnithopterAmbassadorStep = { actor: entrant, + action: { ...original.shipment.step.action, amount: mode === 'twoGroups' ? 2 : 1 } }; + game = actSemutaOrnithopterAmbassador(game, shipment.actor, shipment.action, branchActions); + game = advanceBasicSemutaAmbassador(game, basicSemutaAmbassadorClean, branchActions); + const afterShipment = structuredClone(game); + assert.equal(basicSemutaAmbassadorPlayer(game, entrant).shipped, true); + let firstMove: SemutaOrnithopterAmbassadorStep | null = null; + if (mode === 'twoGroups') { + firstMove = { actor: entrant, action: { type: 'move', from: 'habbanya_ridge_flat:17', + amount: 1, territory: 'habbanya_ridge_flat', sector: 18, + movementCard: SEMUTA_AMBASSADOR_ORNITHOPTER, ornithopter: 'twoGroups' } }; + game = actSemutaOrnithopterAmbassador(game, entrant, firstMove.action, branchActions); + game = advanceBasicSemutaAmbassador(game, basicSemutaAmbassadorClean, branchActions); + assert.equal(game.ornithopter?.completed, 1); + assert.equal(basicSemutaAmbassadorPlayer(game, entrant).moved, 1); + assert.equal(basicSemutaAmbassadorPlayer(game, entrant).forces['habbanya_ridge_flat:18'], 1); + } + const move: Action = { ...original.arrival.step.action, + ...(mode === 'range3' ? { movementCard: SEMUTA_AMBASSADOR_ORNITHOPTER, ornithopter: 'range3' } + : { ornithopterEvent: game.ornithopter!.event }) }; + assert.equal(basicSemutaAmbassadorPlayer(game, entrant).forces['habbanya_ridge_flat:17'], 1); + assert.ok(!game.pendingAmbassador); + assert.ok(basicSemutaAmbassadorClean(game)); + assert.deepEqual(semutaOrnithopterAmbassadorInventory(game), originalInventory); + return { game, original, prefixActions, branchActions, + actions: [...prefixActions, ...branchActions], sourceActionCount: prefixActions.length + branchActions.length, + sourcePrefixActionCount: prefixActions.length, branchActionCount: branchActions.length, + beforeShipment, shipment, afterShipment, firstMove, move, mode, effect, copied, + owner: original.owner, beneficiary: original.beneficiary, entrant, claimant, + ornithopter: SEMUTA_AMBASSADOR_ORNITHOPTER, semuta: SEMUTA_DRUG_ID, + token: original.token, territory: original.territory, sector: original.sector, + location: original.location, originalInventory, + endMovement: { type: 'endMovement' } satisfies Action, + staging: [ + 'Reuse only the genuine public Basic Ecaz/CHOAM/Richese/Guild prefix before Guild shipment: original selected deal/Spice positions and scalar Ambassador packet, paid canonical Ornithopter cache purchase1, normal Ecaz purchase1 and actual placement1. The constructor’s later ordinary-movement/trigger branch is not replayed or counted in this branch.', + 'At that clean boundary, move only the original cache Semuta to Richese hand as an explicitly conserved prior acquisition. No card copy, wallet, force, phase, alliance, token, pending reaction or receipt is installed.', + mode === 'twoGroups' + ? 'Actually ship two Guild reserves, play the paid original Ornithopter and move one original counter from Habbanya Ridge Flat17 to its printed sector18. The first group has no new-territory arrival. Its original flight event binds the remaining final group to Habbanya Sietch17; that final move remains a caller action.' + : 'Actually ship one Guild reserve to Habbanya Ridge Flat17. The paid original range-three Ornithopter move into the real Ambassador at Habbanya Sietch17 remains a caller action.', + ], + }; +} +export type SemutaOrnithopterAmbassadorFixture = ReturnType; + +/** The first discarded card has already retired before this actual entry opens. */ +export function openSemutaOrnithopterAmbassadorGrant( + source: Game, fixture: Pick, + actions: SemutaOrnithopterAmbassadorStep[] = [], +): Game { + let game = source; + assert.equal(game.pendingAmbassador?.stage, 'offer'); + assert.equal(game.pendingAmbassador.arrivalCause, 'movement'); + assert.equal(game.pendingAmbassador.entrant, fixture.entrant); + assert.equal(game.pendingAmbassador.token, fixture.token); + const event = game.pendingAmbassador.event; + game = actSemutaOrnithopterAmbassador(game, fixture.owner, + { type: 'decision', event, trigger: true, beneficiary: fixture.beneficiary, choice: 'effect' }, actions); + if (fixture.copied) { + assert.equal(game.pendingAmbassador?.stage, 'copy'); + assert.ok(game.pendingAmbassador.copyChoices.includes(fixture.effect)); + game = actSemutaOrnithopterAmbassador(game, fixture.beneficiary, + { type: 'decision', event, effect: fixture.effect }, actions); + } + assert.equal(game.pendingAmbassador?.stage, 'cards'); + assert.equal(game.pendingAmbassador.effect, fixture.effect); + assert.equal(game.pendingAmbassador.arrivalCause, 'movement'); + assert.equal(viewGame(game, fixture.beneficiary).decision?.kind, 'ecazAmbassador'); + return game; +} diff --git a/tests/fixture-semuta-ornithopter-terror.ts b/tests/fixture-semuta-ornithopter-terror.ts new file mode 100644 index 00000000..3ff5ebcd --- /dev/null +++ b/tests/fixture-semuta-ornithopter-terror.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { actTruthMovement } from './fixture-truth-movement'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +export type SemutaOrnithopterTerrorOptions = { + kind?: 'sabotage' | 'robbery'; + mode?: 'range3' | 'twoGroups'; + fullHand?: boolean; +}; +export type SemutaOrnithopterTerrorStep = { actor: string; action: Action }; +export const SEMUTA_TERROR_ORNITHOPTER = 'richese-ornithopter'; +export const actSemutaOrnithopterTerror = actTruthMovement; +export const semutaOrnithopterTerrorPlayer = (game: Game, actor: string) => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const semutaOrnithopterTerrorClean = (game: Game) => + !game.response && !game.phaseOpening && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard; +export function semutaOrnithopterTerrorInventory(game: Game): string[] { + const ids = [...game.deck, ...game.discard, ...(game.ornithopter ? [game.ornithopter.card] : []), ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'Each original physical card retains one location'); + return ids; +} + +/** Only actual native controls. Terror placement/revelation, card choices and + * the Guild's shipped movement remain explicit caller choices. */ +export function nextSemutaOrnithopterTerrorStep(game: Game): SemutaOrnithopterTerrorStep | null { + if (!game.response && !game.phaseOpening && game.decision) { + const d = game.decision, event = game.richeseBidding?.event; + if (['moritaniPlacement', 'moritaniTerror'].includes(d.kind)) return null; + if (d.kind === 'moritaniSetup' && game.status === 'setup' && !game.setupStage) { + const destination = TERRITORIES.find(t => t.id === 'oh_gap'); assert.ok(destination); + return { actor: d.player, action: { type: 'decision', + territory: destination.id, sector: destination.sectors[0] } }; + } + if (d.kind === 'richeseCache') { + const card = game.turn === 1 + ? game.richeseCache?.find(card => card.id === SEMUTA_TERROR_ORNITHOPTER) + : game.richeseCache?.find(card => card.id !== SEMUTA_DRUG_ID); + assert.ok(card); + return { actor: d.player, action: { type: 'decision', event, card: card.id, + method: 'onceAround', direction: 'clockwise' } }; + } + if (d.kind === 'richeseUnbid') + return { actor: d.player, action: { type: 'decision', event, keep: false } }; + } + if (game.status === 'playing' && semutaOrnithopterTerrorClean(game)) { + if (game.phase === 0 && game.stormPending === null) { + const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); assert.ok(actor); + return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } }; + } + const lot = game.richeseAuction; + if (lot && !lot.outcome && lot.cardId === SEMUTA_TERROR_ORNITHOPTER && + lot.active === 'guild' && lot.bid === 0) + return { actor: 'guild', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } }; + if (game.phase === 5 && game.active === 'guild' && + semutaOrnithopterTerrorPlayer(game, 'guild').shipped) return null; + } + return nextRicheseStrongholdsNativeStep(game); +} +export function advanceSemutaOrnithopterTerror(game: Game, until: (state: Game) => boolean, + actions: SemutaOrnithopterTerrorStep[] = []): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Terror source ended before its owned boundary'); + const next = nextSemutaOrnithopterTerrorStep(game); + assert.ok(next, 'Supply the original Terror, placement or owned movement choice explicitly'); + game = actSemutaOrnithopterTerror(game, next.actor, next.action, actions); + } + throw Error('The native Ornithopter/Terror programme did not reach its finite boundary'); +} + +/** Continue this fresh group only, never the later native Terror decision. */ +export function passSemutaOrnithopterTerror(source: Game): Game { + const pending = source.pendingTreacheryDiscard; + assert.equal(pending?.reaction?.stage, 'offer'); + const event = pending!.batch.event; + let game = source; + for (const player of source.players) { + const current = game.pendingTreacheryDiscard, reaction = current?.reaction; + if (!current || current.batch.event !== event || reaction?.stage !== 'offer') break; + if (!reaction.passed.includes(player.id)) + game = actSemutaOrnithopterTerror(game, player.id, { type: 'semutaPass', event }); + } + return game; +} +export function claimSemutaOrnithopterTerror(source: Game, actor: string, card: string): Game { + const event = source.pendingTreacheryDiscard!.batch.event; + let game = actSemutaOrnithopterTerror(source, actor, { type: 'semutaCommit', event }); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = actSemutaOrnithopterTerror(game, actor, { type: 'semutaSelect', event, card }); + return game; +} + +/** Genuine Basic native setup, paid cache purchase, Mentat placement and + * funded shipment. Only unopened deck/Spice ordering/scalar lotteries and + * labelled conserved Semuta/overflow/full-hand acquisitions are controlled. */ +export function createSemutaOrnithopterTerrorFixture(options: SemutaOrnithopterTerrorOptions = {}) { + const kind = options.kind ?? 'sabotage', mode = options.mode ?? 'range3'; + const owner = 'moritani', claimant = 'richese', entrant = 'guild'; + const territory = 'carthag', sector = 11, origin = 'imperial_basin:10'; + let game = createGame('SEMUTAORNITHOPTERTERROR', + newPlayer(owner, 'Moritani', 'moritani'), false, ['ecaz', 'choam']); + joinGame(game, newPlayer(claimant, 'Richese', 'richese')); + joinGame(game, newPlayer(entrant, 'Guild', 'guild')); + const actions: SemutaOrnithopterTerrorStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaOrnithopterTerror(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { + game = advanceSemutaOrnithopterTerror(game, until, actions); + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); + assert.equal(game.semutaPreview, true); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && + !game.techTokens && !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + assert.ok(game.players.every(player => player.hand.length === 0)); + const originalInventory = semutaOrnithopterTerrorInventory(game); + const originalMainDeck = game.deck.map(card => card.id).sort(); + const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 3); + assert.equal(ordinary.length, 3); + const deal = ordinary.map(card => card.id); + const dealOrder = deal.map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + for (const [index, player] of game.players.entries()) + assert.deepEqual(player.hand.map(card => card.id), [deal[index]]); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder = [0, 1].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && + !card.discovery && !['oh_gap', 'tueks_sietch', 'imperial_basin', 'arrakeen', 'carthag'].includes(card.territory)); + assert.ok(from >= position); + const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + return { position, from, card: structuredClone(card) }; + }); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache'); + const beforeCache = structuredClone(game), acquisitionStart = actions.length; + reach(state => semutaOrnithopterTerrorClean(state) && + semutaOrnithopterTerrorPlayer(state, entrant).hand.some(card => card.id === SEMUTA_TERROR_ORNITHOPTER)); + const acquisition = { before: beforeCache, steps: structuredClone(actions.slice(acquisitionStart)), + after: structuredClone(game) }; + assert.equal(semutaOrnithopterTerrorPlayer(game, entrant).spice, + semutaOrnithopterTerrorPlayer(beforeCache, entrant).spice - 1); + reach(state => state.turn === 1 && state.phase === 8 && + !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement'); + const beforePlacement = structuredClone(game); + const terror = game.moritaniTerror!.tokens.find(token => token.kind === kind && token.status === 'available'); + assert.ok(terror); + const token = terror.id, placementStep: SemutaOrnithopterTerrorStep = { + actor: owner, action: { type: 'decision', token, territory }, + }; + act(placementStep.actor, placementStep.action); + reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && + semutaOrnithopterTerrorClean(state)); + const beforeShipment = structuredClone(game); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token)!.status, 'placed'); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token)!.location, territory); + assert.equal(game.moritaniTerror!.placementTurn, 1); + const shipmentStep: SemutaOrnithopterTerrorStep = { actor: entrant, action: { + type: 'ship', territory: 'imperial_basin', sector: 10, amount: mode === 'twoGroups' ? 2 : 1, + elite: 0, allyPayment: 0, + } }; + act(shipmentStep.actor, shipmentStep.action); reach(semutaOrnithopterTerrorClean); + const afterShipment = structuredClone(game); + assert.equal(semutaOrnithopterTerrorPlayer(game, entrant).spice, + semutaOrnithopterTerrorPlayer(beforeShipment, entrant).spice - Number(shipmentStep.action.amount)); + const cache = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(cache >= 0); + semutaOrnithopterTerrorPlayer(game, claimant).hand.push(game.richeseCache!.splice(cache, 1)[0]); + if (kind === 'robbery') while (semutaOrnithopterTerrorPlayer(game, owner).hand.length < 4) { + const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind)); + assert.ok(index >= 0); semutaOrnithopterTerrorPlayer(game, owner).hand.push(game.deck.splice(index, 1)[0]); + } + if (options.fullHand) while (semutaOrnithopterTerrorPlayer(game, claimant).hand.length < 4) { + const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind)); + assert.ok(index >= 0); semutaOrnithopterTerrorPlayer(game, claimant).hand.push(game.deck.splice(index, 1)[0]); + } + assert.ok(game.players.every(player => player.hand.length <= 4)); + const gift = semutaOrnithopterTerrorPlayer(game, owner).hand[0].id; + const overflowDiscard = gift; + let firstMove: { before: Game; step: SemutaOrnithopterTerrorStep; after: Game } | null = null; + if (mode === 'twoGroups') { + const before = structuredClone(game), step: SemutaOrnithopterTerrorStep = { actor: entrant, action: { + type: 'move', movementCard: SEMUTA_TERROR_ORNITHOPTER, ornithopter: 'twoGroups', + from: origin, amount: 1, territory: 'arrakeen', sector: 10, + } }; + act(step.actor, step.action); reach(semutaOrnithopterTerrorClean); + firstMove = { before, step, after: structuredClone(game) }; + assert.equal(game.ornithopter!.completed, 1); + } + const move: Action = { type: 'move', from: origin, amount: 1, territory, sector, + ...(mode === 'range3' + ? { movementCard: SEMUTA_TERROR_ORNITHOPTER, ornithopter: 'range3' } + : { ornithopterEvent: game.ornithopter!.event }) }; + const entrantCard = semutaOrnithopterTerrorPlayer(game, entrant).hand + .find(card => card.id !== SEMUTA_TERROR_ORNITHOPTER); + assert.ok(entrantCard); + assert.deepEqual(semutaOrnithopterTerrorInventory(game), originalInventory); + assert.ok(!game.pendingTerrorEntry); + return { game, move, kind, mode, fullHand: !!options.fullHand, owner, entrant, claimant, + ornithopter: SEMUTA_TERROR_ORNITHOPTER, semuta: SEMUTA_DRUG_ID, token, territory, sector, + origin, location: territory + ':' + sector, gift, overflowDiscard, entrantCard: entrantCard.id, + deckTop: structuredClone(game.deck[0]), originalInventory, originalMainDeck, + lobby, setup, beforeStartingDeal, deal, dealOrder, afterSetup, spiceOrder, + acquisition, beforePlacement, placementStep, beforeShipment, shipmentStep, afterShipment, firstMove, + actions, sourceActionCount: actions.length, + balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])), + claimantHandSize: semutaOrnithopterTerrorPlayer(game, claimant).hand.length, + staging: [ + 'Original public Basic Moritani/Richese/Guild setup uses the selected E2/E3 decks and native cache. Only three unopened starting-card positions, two unopened ordinary Spice positions and scalar lotteries are selected. Native Moritani places its six starting counters in printed Oh Gap; no force, wallet, phase, token or pending receipt is installed.', + 'Guild actually buys the original cache Ornithopter for one spice during turn one. The native turn-one Mentat opportunity actually places the selected hidden Terror in Carthag; actual first-turn zero dials, next-turn1+1 dials and quiet native controls reach turn two. Guild then pays for its real one/two-force Imperial Basin shipment. Two-group mode performs its real first move to empty Arrakeen before the caller-owned final movement.', + 'At the clean shipped boundary, explicitly conserve the original cache Semuta into Richese hand. Robbery additionally fills Moritani to its actual four-card limit using original ordinary deck cards, and optional full-hand cases likewise fill Richese. These are labelled prior-card acquisitions, not a played auction/trade. No card identity is duplicated. The final move, Semuta choices, Terror revelation, actual Robbery draw/discard and optional Sabotage gift remain caller actions.', + ], + }; +} +export type SemutaOrnithopterTerrorFixture = ReturnType; diff --git a/tests/semuta-ornithopter-ambassador.test.ts b/tests/semuta-ornithopter-ambassador.test.ts new file mode 100644 index 00000000..02eb6841 --- /dev/null +++ b/tests/semuta-ornithopter-ambassador.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + createSemutaOrnithopterAmbassadorFixture as fixture, + actSemutaOrnithopterAmbassador as act, + semutaOrnithopterAmbassadorPlayer as player, + semutaOrnithopterAmbassadorInventory as inventory, + passSemutaOrnithopterAmbassador as pass, + claimSemutaOrnithopterAmbassador as claim, + openSemutaOrnithopterAmbassadorGrant as grant, + type SemutaOrnithopterAmbassadorFixture, +} from './fixture-semuta-ornithopter-ambassador'; + +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); + assert.deepEqual(game, before); +} +function movement(game: Game, f: SemutaOrnithopterAmbassadorFixture) { + assert.equal(player(game, f.entrant).moved, f.mode === 'twoGroups' ? 2 : 1); + assert.equal(player(game, f.entrant).ornithopterExtraMoves ?? 0, f.mode === 'twoGroups' ? 1 : 0); + assert.equal(player(game, f.entrant).forces[f.location], 1); + assert.equal(player(game, f.entrant).forces['habbanya_ridge_flat:17'] ?? 0, 0); + assert.equal(player(game, f.entrant).forces['habbanya_ridge_flat:18'] ?? 0, f.mode === 'twoGroups' ? 1 : 0); + assert.equal(game.active, f.entrant); assert.equal(game.ornithopter, null); + assert.equal(player(game, f.entrant).spice, player(f.game, f.entrant).spice); + assert.deepEqual(inventory(game), f.originalInventory); +} + +for (const mode of ['range3', 'twoGroups'] as const) + for (const effect of ['choam', 'ixians'] as const) + for (const firstClaim of [false, true]) void test( + `native Basic ${mode} retires Ornithopter before ${effect} with ${firstClaim ? 'flight' : 'grant'} recovery`, () => { + const f = fixture({ mode, effect }), source = structuredClone(f.game); + const pending = act(source, f.entrant, f.move), first = pending.pendingTreacheryDiscard; + assert.equal(first?.reaction?.stage, 'offer'); + assert.equal(first.continuation.kind, 'ornithopterDiscard'); + assert.equal(first.batch.cause, 'ornithopter:move'); + assert.deepEqual(first.batch.entries.map(entry => entry.card.id), [f.ornithopter]); + assert.ok(!pending.pendingAmbassador, 'The original arrival waits for card disposal'); + movement(pending, f); assert.deepEqual(source, f.game); + let game = firstClaim ? claim(pending, f.claimant, f.ornithopter) : pass(pending); + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(game.pendingAmbassador?.stage, 'offer'); + assert.equal(game.pendingAmbassador.arrivalCause, 'movement'); + movement(game, f); + rejected(game, f.claimant, { type: 'semutaCommit', event: first.batch.event }); + game = grant(game, f); + const event = game.pendingAmbassador!.event; + const cards = player(game, f.beneficiary).hand.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id); + const beforeIncome = player(game, f.beneficiary).spice; + const beforeDeck = structuredClone(game.deck), replacement = game.deck[0]; assert.ok(replacement); + const native = act(game, f.beneficiary, { type: 'decision', event, cards }); + const next = native.pendingTreacheryDiscard; + assert.equal(next?.reaction?.stage, 'offer'); assert.equal(next.continuation.kind, 'ambassador'); + assert.notEqual(next.batch.event, first.batch.event); + assert.equal(next.batch.cause, `ambassador:${effect}`); + assert.deepEqual(next.batch.entries.map(entry => entry.card.id), cards); + assert.ok(next.batch.entries.every(entry => !entry.publicFace)); + assert.equal(player(native, f.beneficiary).spice, beforeIncome + (effect === 'choam' ? cards.length * 3 : 0)); + assert.deepEqual(native.deck, beforeDeck, 'The replacement draw waits for this second fresh group'); + for (const p of native.players) assert.deepEqual(viewGame(native, p.id).semutaReaction!.candidates, []); + assert.equal(viewGame(native, f.claimant).semutaReaction!.canCommit, !firstClaim); + rejected(native, f.claimant, { type: 'semutaCommit', event: first.batch.event }); + game = firstClaim ? pass(native) : claim(native, f.claimant, cards[0]); + assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingAmbassador, null); + assert.equal(player(game, f.beneficiary).spice, beforeIncome + (effect === 'choam' ? cards.length * 3 : 0)); + assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ornithopter), firstClaim); + assert.equal(player(game, f.claimant).hand.some(card => card.id === cards[0]), !firstClaim); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); + assert.equal(game.discard.filter(card => card.id === f.ornithopter).length, Number(!firstClaim)); + if (effect === 'ixians') { + assert.ok(player(game, f.beneficiary).hand.some(card => card.id === replacement.id)); + assert.deepEqual(game.deck, beforeDeck.slice(1)); + } else assert.deepEqual(game.deck, beforeDeck); + movement(game, f); + game = act(game, f.entrant, f.endMovement); + assert.ok(!game.movementRemaining?.includes(f.entrant)); + assert.deepEqual(inventory(game), f.originalInventory); + }); + +for (const mode of ['range3', 'twoGroups'] as const) void test( + `native Basic ${mode} with an empty CHOAM grant creates no second discard or payout`, () => { + const f = fixture({ mode, effect: 'choam' }); + let game = pass(act(f.game, f.entrant, f.move)); + game = grant(game, f); + const spice = player(game, f.beneficiary).spice, deck = structuredClone(game.deck); + game = act(game, f.beneficiary, { type: 'decision', event: game.pendingAmbassador!.event, cards: [] }); + assert.ok(!game.pendingTreacheryDiscard); assert.equal(game.pendingAmbassador, null); + assert.equal(player(game, f.beneficiary).spice, spice); assert.deepEqual(game.deck, deck); + assert.ok(player(game, f.claimant).hand.some(card => card.id === f.semuta)); + movement(game, f); + }); + +void test('an actual BG-copy Ixian Ambassador follows the retired two-group card into one original replacement draw', () => { + const f = fixture({ mode: 'twoGroups', effect: 'ixians', copied: true }); + let game = pass(act(f.game, f.entrant, f.move)); game = grant(game, f); + const chosen = player(game, f.beneficiary).hand[0].id, deck = structuredClone(game.deck); + game = act(game, f.beneficiary, { type: 'decision', event: game.pendingAmbassador!.event, cards: [chosen] }); + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + game = claim(game, f.claimant, chosen); + assert.ok(player(game, f.beneficiary).hand.some(card => card.id === deck[0].id)); + assert.deepEqual(game.deck, deck.slice(1)); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'removed'); + movement(game, f); +}); + +void test('all existing profiles legally answer both native fresh groups without replaying the completed flight', () => { + const f = fixture({ effect: 'choam', mode: 'twoGroups', copied: true }); + for (const difficulty of DIFFICULTIES) { + let game = act(f.game, f.entrant, f.move); + const finish = (source: Game) => { + let current = source; + for (let count = 0; current.pendingTreacheryDiscard && count < 16; count++) { + const reaction = current.pendingTreacheryDiscard.reaction!; + const actor = reaction.stage === 'select' ? reaction.player + : current.players.find(p => !reaction.passed.includes(p.id))!.id; + const view = viewGame(current, actor); view.players.find(p => p.id === actor)!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action); + current = act(current, actor, action); + } + assert.equal(current.pendingTreacheryDiscard, null); return current; + }; + game = finish(game); game = grant(game, f); + const cards = player(game, f.beneficiary).hand.slice(0, 2).map(card => card.id); + const spice = player(game, f.beneficiary).spice; + game = act(game, f.beneficiary, { type: 'decision', event: game.pendingAmbassador!.event, cards }); + game = finish(game); + assert.equal(player(game, f.beneficiary).spice, spice + 6); + assert.equal(game.pendingAmbassador, null); movement(game, f); + } +}); diff --git a/tests/semuta-ornithopter-native-controls.test.tsx b/tests/semuta-ornithopter-native-controls.test.tsx new file mode 100644 index 00000000..2edf6ecd --- /dev/null +++ b/tests/semuta-ornithopter-native-controls.test.tsx @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SemutaReaction } from '../components/semuta-reaction'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { + createSemutaOrnithopterAmbassadorFixture as fixture, + actSemutaOrnithopterAmbassador as act, + openSemutaOrnithopterAmbassadorGrant as grant, + semutaOrnithopterAmbassadorPlayer as player, + semutaOrnithopterAmbassadorInventory as inventory, +} from './fixture-semuta-ornithopter-ambassador'; + +type Props = Parameters[0]; +type Control = { children?: unknown; disabled?: boolean; onClick: () => void }; +function controls(node: unknown): Control[] { + if (Array.isArray(node)) return node.flatMap(controls); + if (!node || typeof node !== 'object' || !('props' in node)) return []; + const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props; + return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)]; +} +function label(node: unknown): string { + if (Array.isArray(node)) return node.map(label).join(''); + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (!node || typeof node !== 'object' || !('props' in node)) return ''; + return label((node as ReactElement<{ children?: unknown }>).props.children); +} +const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props)); +function actionFromControl(game: Game, actor: string, text: string, expected: Action): Action { + const sent: Action[] = [], props = { game: viewGame(game, actor), busy: false, + act: (action: Action) => { sent.push(action); } }; + const control = controls(SemutaReaction(props)).find(button => label(button.children) === text); + assert.ok(control, `The actual native fresh group offers ${text}`); + assert.equal(control.disabled, false); + const busy = controls(SemutaReaction({ ...props, busy: true })).find(button => label(button.children) === text); + assert.ok(busy); assert.equal(busy.disabled, true); + busy.onClick(); assert.deepEqual(sent, [], 'Busy callbacks cannot acknowledge, commit or select'); + control.onClick(); assert.deepEqual(sent, [expected]); return sent[0]; +} +function offerAction(game: Game, actor: string, commit = false): Action { + const reaction = viewGame(game, actor).semutaReaction; assert.ok(reaction?.stage === 'offer'); + return actionFromControl(game, actor, commit ? 'Commit Semuta Drug' : 'Continue', + { type: commit ? 'semutaCommit' : 'semutaPass', event: reaction.event }); +} +function continueGroup(source: Game): Game { + const pending = source.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer'); + const event = pending.batch.event; + let game = source; + for (const seat of source.players) { + const current = game.pendingTreacheryDiscard; + if (!current || current.batch.event !== event) break; + assert.equal(current.reaction?.stage, 'offer'); + if (!current.reaction!.passed.includes(seat.id)) + game = act(game, seat.id, offerAction(game, seat.id)); + } + return game; +} +function assertMoved(game: Game, actor: string, twoGroups: boolean) { + const mover = player(game, actor); + assert.equal(mover.moved, twoGroups ? 2 : 1); + assert.equal(mover.ornithopterExtraMoves ?? 0, twoGroups ? 1 : 0); + assert.equal(mover.forces['habbanya_ridge_sietch:17'], 1); + if (twoGroups) assert.equal(mover.forces['habbanya_ridge_flat:18'], 1); + assert.equal(game.ornithopter, null); +} + +void test('native two-group Continue then CHOAM Commit/Select keeps separate events and the original six-spice credit', () => { + const f = fixture({ effect: 'choam', mode: 'twoGroups' }); + let game = act(f.game, f.entrant, f.move); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ornithopterDiscard'); + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const firstEvent = game.pendingTreacheryDiscard!.batch.event, moved = structuredClone(player(game, f.entrant).forces); + const original = structuredClone(game); + for (let reading = 0; reading < 2; reading++) { + const html = markup({ game: viewGame(game, f.claimant), busy: false, act() {} }); + assert.match(html, /Continue when you are ready/); + assert.match(html, />Continue<\/button>/); + assert.match(html, />Commit Semuta Drug<\/button>/); + } + assert.deepEqual(game, original, 'Reading the retirement offer never advances its original arrival'); + game = continueGroup(game); + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(game.pendingAmbassador?.stage, 'offer'); + assertMoved(game, f.entrant, true); + game = grant(game, f); + const cards = player(game, f.beneficiary).hand.slice(0, 2).map(card => card.id); + assert.equal(cards.length, 2); + const spice = player(game, f.beneficiary).spice; + game = act(game, f.beneficiary, + { type: 'decision', event: game.pendingAmbassador!.event, cards }); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador'); + const secondEvent = game.pendingTreacheryDiscard!.batch.event; + assert.notEqual(secondEvent, firstEvent); + assert.equal(player(game, f.beneficiary).spice, spice + 6); + assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates, []); + const beforeCommit = structuredClone(game); + assert.throws(() => act(game, f.claimant, { type: 'semutaCommit', event: firstEvent })); + assert.deepEqual(game, beforeCommit, 'The retired flight event cannot claim the later private grant'); + game = act(game, f.claimant, offerAction(game, f.claimant, true)); + const selection = viewGame(game, f.claimant).semutaReaction!; + assert.equal(selection.stage, 'select'); assert.equal(selection.event, secondEvent); + assert.deepEqual(selection.candidates.map(card => card.id).sort(), [...cards].sort()); + const beforeReading = structuredClone(game); + for (let reading = 0; reading < 2; reading++) { + const html = markup({ game: viewGame(game, f.claimant), busy: false, act() {} }); + assert.match(html, /Choose one eligible freshly discarded/); + assert.match(html, /This choice cannot be declined or canceled/); + assert.doesNotMatch(html, />Continue<\/button>/); + } + assert.deepEqual(game, beforeReading, 'Selection persists without another payout or movement'); + const card = selection.candidates.find(card => card.id === cards[0]); assert.ok(card); + const selected = actionFromControl(game, f.claimant, `Claim ${card.name} (${card.id})`, + { type: 'semutaSelect', event: secondEvent, card: card.id }); + game = act(game, f.claimant, selected); + assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingAmbassador, null); + assert.equal(player(game, f.beneficiary).spice, spice + 6); + assert.ok(player(game, f.claimant).hand.some(held => held.id === card.id)); + assert.equal(game.discard.filter(held => held.id === f.semuta).length, 1); + assert.equal(game.discard.filter(held => held.id === f.ornithopter).length, 1); + assert.deepEqual(player(game, f.entrant).forces, moved); + assertMoved(game, f.entrant, true); + assert.deepEqual(inventory(game), f.originalInventory); +}); + +void test('native first-card Commit auto-claims Ornithopter before the BG-copied Ix replacement waits for actual Continues', () => { + const f = fixture({ effect: 'ixians', mode: 'range3', copied: true }); + let game = act(f.game, f.entrant, f.move); + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const firstEvent = game.pendingTreacheryDiscard!.batch.event; + game = act(game, f.claimant, offerAction(game, f.claimant, true)); + assert.equal(game.pendingTreacheryDiscard, null, 'The actual one-card flight group needs no selection stage'); + assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ornithopter)); + assert.ok(!player(game, f.claimant).hand.some(card => card.id === f.semuta)); + assertMoved(game, f.entrant, false); + game = grant(game, f); + const discard = player(game, f.beneficiary).hand[0].id; + const next = structuredClone(game.deck[0]); assert.ok(next); + const deckSize = game.deck.length, spice = player(game, f.beneficiary).spice; + game = act(game, f.beneficiary, + { type: 'decision', event: game.pendingAmbassador!.event, cards: [discard] }); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador'); + assert.notEqual(game.pendingTreacheryDiscard!.batch.event, firstEvent); + assert.equal(game.deck.length, deckSize); assert.equal(game.deck[0].id, next.id); + const before = structuredClone(game); + for (const seat of game.players) { + const view = viewGame(game, seat.id); + assert.equal(view.semutaReaction!.canCommit, false, 'The single Semuta was actually spent on the first card'); + const html = markup({ game: view, busy: false, act() {} }); + assert.match(html, /Continue when you are ready/); + assert.doesNotMatch(html, />Commit Semuta Drug<\/button>/); + } + assert.deepEqual(game, before, 'Reading the second group cannot draw its pending replacement'); + game = continueGroup(game); + assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingAmbassador, null); + assert.equal(game.deck.length, deckSize - 1); + assert.ok(player(game, f.beneficiary).hand.some(card => card.id === next.id)); + assert.equal(player(game, f.beneficiary).spice, spice); + assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ornithopter)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); + assertMoved(game, f.entrant, false); + assert.deepEqual(inventory(game), f.originalInventory); +}); diff --git a/tests/semuta-ornithopter-terror.test.ts b/tests/semuta-ornithopter-terror.test.ts new file mode 100644 index 00000000..a7aa96a9 --- /dev/null +++ b/tests/semuta-ornithopter-terror.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { + createSemutaOrnithopterTerrorFixture as fixture, actSemutaOrnithopterTerror as act, + semutaOrnithopterTerrorPlayer as player, semutaOrnithopterTerrorInventory as inventory, + passSemutaOrnithopterTerror as pass, claimSemutaOrnithopterTerror as claim, + type SemutaOrnithopterTerrorFixture, +} from './fixture-semuta-ornithopter-terror'; + +const balances = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice])); +const moves = (game: Game) => game.log.filter(row => row.text.startsWith('Guild moved ')).length; +function physical(game: Game, f: SemutaOrnithopterTerrorFixture) { + assert.equal(player(game, f.entrant).forces[f.location], 1); + assert.equal(player(game, f.entrant).forces[f.origin] ?? 0, 0); + assert.equal(player(game, f.entrant).forces['arrakeen:10'] ?? 0, f.mode === 'twoGroups' ? 1 : 0); + assert.equal(player(game, f.entrant).moved, f.mode === 'twoGroups' ? 2 : 1); + assert.equal(player(game, f.entrant).ornithopterExtraMoves ?? 0, f.mode === 'twoGroups' ? 1 : 0); + assert.equal(game.ornithopter, null); + for (const p of game.players) + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, amount) => sum + amount, 0), 20); + assert.deepEqual(inventory(game), f.originalInventory); + assert.deepEqual(balances(game), f.balances); +} +function start(f: SemutaOrnithopterTerrorFixture) { + const game = act(f.game, f.entrant, f.move), pending = game.pendingTreacheryDiscard; + assert.equal(pending?.continuation.kind, 'ornithopterDiscard'); + assert.equal(pending.reaction?.stage, 'offer'); + assert.ok(!game.pendingTerrorEntry, 'Original Terror arrival waits for the played-card discard'); + assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'placed'); + assert.equal(moves(game), moves(f.game), 'The committed movement log belongs to the original later arrival suffix'); + physical(game, f); return game; +} +function nativeDiscard(source: Game, f: SemutaOrnithopterTerrorFixture) { + assert.equal(source.pendingTreacheryDiscard, null); + assert.equal(source.pendingTerrorEntry?.stage, 'offer'); + assert.equal(source.pendingTerrorEntry.cause, 'movement'); + assert.equal(source.pendingTerrorEntry.token, f.token); + assert.equal(source.decision?.player, f.owner); + assert.equal(moves(source), moves(f.game) + 1); + let game = act(source, f.owner, { type: 'decision', reveal: true }); + if (f.kind === 'robbery') { + assert.equal(game.pendingTerrorEntry?.stage, 'robbery'); + const size = game.deck.length; + assert.equal(size, f.game.deck.length); + game = act(game, f.owner, { type: 'decision', choice: 'card' }); + assert.equal(game.pendingTerrorEntry?.stage, 'discard'); + assert.equal(player(game, f.owner).hand.length, 5); + assert.ok(player(game, f.owner).hand.some(card => card.id === f.deckTop.id)); + assert.equal(game.deck.length, size - 1, 'Robbery performs its original top draw exactly once'); + game = act(game, f.owner, { type: 'decision', card: f.overflowDiscard }); + } + const pending = game.pendingTreacheryDiscard; + assert.equal(pending?.continuation.kind, 'terrorDiscard'); + assert.equal(pending.reaction?.stage, 'offer'); + assert.equal(pending.batch.entries.length, 1); + assert.equal(pending.batch.entries[0].publicFace, false); + assert.equal(pending.batch.entries[0].card.id, f.kind === 'sabotage' ? f.entrantCard : f.overflowDiscard); + assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed'); + assert.equal(game.moritaniTerror!.tokens.filter(token => token.status === 'removed').length, 1); + physical(game, f); return game; +} +function finish(source: Game, f: SemutaOrnithopterTerrorFixture, give = false) { + let game = source; + if (f.kind === 'sabotage') { + assert.equal(game.pendingTerrorEntry?.stage, 'gift'); + assert.equal(game.decision?.player, f.owner); + game = act(game, f.owner, give + ? { type: 'decision', card: f.gift } + : { type: 'decision', decline: true }); + if (give) assert.ok(player(game, f.entrant).hand.some(card => card.id === f.gift)); + } + assert.ok(!game.pendingTerrorEntry); + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(game.decision, null); assert.equal(game.response, null); + assert.equal(game.active, f.entrant); + assert.equal(moves(game), moves(f.game) + 1); + if (f.kind === 'robbery') { + assert.equal(game.deck.length, f.game.deck.length - 1); + assert.equal(player(game, f.owner).hand.length, 4); + } + physical(game, f); + const before = structuredClone(game); + assert.throws(() => act(game, f.owner, { type: 'decision', reveal: true })); + assert.deepEqual(game, before, 'A completed native Terror effect cannot be revealed or applied again'); + return act(game, f.entrant, { type: 'endMovement' }); +} + +for (const kind of ['sabotage', 'robbery'] as const) + for (const mode of ['range3', 'twoGroups'] as const) + void test('native Basic ' + kind + ' follows ' + mode + ' Ornithopter discard before its own private Semuta claim', () => { + const f = fixture({ kind, mode }); + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false); + assert.equal(f.afterSetup.status, 'playing'); + let game = start(f); + const firstEvent = game.pendingTreacheryDiscard!.batch.event; + game = pass(game); + assert.ok(player(game, f.claimant).hand.some(card => card.id === f.semuta)); + assert.ok(!player(game, f.claimant).hand.some(card => card.id === f.ornithopter)); + game = nativeDiscard(game, f); + assert.notEqual(game.pendingTreacheryDiscard!.batch.event, firstEvent); + assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates, [], + 'The private native discard is not inspected before committing Semuta'); + const wanted = kind === 'sabotage' ? f.entrantCard : f.overflowDiscard; + game = claim(game, f.claimant, wanted); + assert.ok(player(game, f.claimant).hand.some(card => card.id === wanted)); + assert.equal(player(game, f.claimant).hand.length, f.claimantHandSize); + assert.equal(game.discard.filter(card => card.id === f.ornithopter).length, 1); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); + game = finish(game, f, kind === 'sabotage'); + assert.deepEqual(inventory(game), f.originalInventory); + }); + +for (const kind of ['sabotage', 'robbery'] as const) + void test('claiming a full-hand ' + kind + ' entrant flight preserves native arrival without a second Semuta spend', () => { + const f = fixture({ kind, mode: 'twoGroups', fullHand: true }); + let game = start(f); + game = claim(game, f.claimant, f.ornithopter); + assert.equal(player(game, f.claimant).hand.length, 4); + assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ornithopter)); + game = nativeDiscard(game, f); + for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false); + game = pass(game); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); + assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ornithopter)); + game = finish(game, f); + assert.deepEqual(inventory(game), f.originalInventory); + }); + +void test('the retired Ornithopter fresh event cannot claim the following private Terror group', () => { + const f = fixture(), first = start(f), stale = first.pendingTreacheryDiscard!.batch.event; + const game = nativeDiscard(pass(first), f), before = structuredClone(game); + assert.notEqual(game.pendingTreacheryDiscard!.batch.event, stale); + assert.throws(() => act(game, f.claimant, { type: 'semutaCommit', event: stale })); + assert.deepEqual(game, before); + const selected = claim(game, f.claimant, f.entrantCard); + assert.ok(player(selected, f.claimant).hand.some(card => card.id === f.entrantCard)); +}); + +void test('full-hand recovery of the Robbery overflow keeps the already completed top draw and movement', () => { + const f = fixture({ kind: 'robbery', fullHand: true }); + let game = nativeDiscard(pass(start(f)), f); + const topAfterDraw = game.deck[0].id; + assert.equal(player(game, f.owner).hand.length, 4); + game = claim(game, f.claimant, f.overflowDiscard); + assert.equal(player(game, f.claimant).hand.length, 4); + assert.equal(game.deck[0].id, topAfterDraw); + assert.ok(player(game, f.owner).hand.some(card => card.id === f.deckTop.id)); + game = finish(game, f); + assert.deepEqual(inventory(game), f.originalInventory); +});