Scope Advanced Kull to the attempted Karama effect
Adopt the supplied Advanced specific-effect wording, preserving Basic and legacy phase-wide semantics. Bind signed semantic purposes through ordinary/special activations, counters, feasibility and settlement; retain unrelated human controls and minimal bot actions. Phase expiry remains provisional; prior counter, BG and deferred-overbid choices stay. Validation: frozen12-file111/111, types/lint/build and two original-action/rendered-table programmes. Explicit unopened deal/auction/spice order supports real paid Baliset and shipments; Advanced retains Karama for Baliset cancellation while Basic stays blocked. Four minimal policies remain legal. Correct stale exchange/phase-market/Fremen fixture assumptions. Recovery schema expectations maintained without running deferred SQL assurance. No full modes, browser/deployed acceptance, saved-game/reset/restart or deployment operation.
This commit is contained in:
1 parent
b82441c834
commit
884a09fa92
24 files changed
+951
-64
No files matched your search
@@ -169,8 +169,9 @@ on a fresh ready Basic/Advanced CHOAM-plus-classic lobby with `choam,ix` decks
|
||||
and no optional modules, following the [safe prototype workflow](docs/DEVELOPMENT.md).
|
||||
The tool requires the exact room/version and a private backup output directory.
|
||||
The preview reserves the interrupted Karama and permits only a different eligible
|
||||
counter; BG interception is before conversion. Unpayable overbid recovery remains
|
||||
guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md).
|
||||
counter; BG interception is before conversion. New Advanced attempts restrict
|
||||
the specific Karama effect; Basic retains phase-wide prevention. Advanced phase
|
||||
expiry is provisional. Unpayable overbid recovery remains guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md).
|
||||
This does not enable public expansion starts or certify full CHOAM.
|
||||
|
||||
For local **CHOAM Nexus Cunning Kull**, use `node --import tsx tools/start-prototype.ts --profile nexus-kull`
|
||||
|
||||
@@ -44,8 +44,9 @@ export function ChoamKull({
|
||||
Decline to continue the original play, or choose a listed physical
|
||||
cost for Kull Wahad. Printed Kull uses its own Worthless Card;
|
||||
CHOAM Nexus Cunning can use any eligible Treachery Card.
|
||||
Successful Kull retains the attempted card and prevents that
|
||||
player’s Karama activations for this phase. A different eligible
|
||||
Successful Kull retains the attempted card and prevents {reaction.scope === 'effect'
|
||||
? <>that player from using {reaction.effect} again this phase; other Karama effects remain available.</>
|
||||
: <>that player’s Karama activations for this phase.</>} A different eligible
|
||||
Karama can prevent Kull before that restriction takes effect.
|
||||
</p>
|
||||
{reaction.blocked && <p className="fine">{reaction.blocked}</p>}
|
||||
|
||||
@@ -175,6 +175,7 @@ import {
|
||||
canUseAsKaramaRole,
|
||||
canUseAsTruthtranceRole,
|
||||
} from '@/game/shrine';
|
||||
import { kullPurposeBlock, kullSpecialPurpose, type KullPurpose } from '@/game/kull-purpose';
|
||||
import { LeaderPortrait } from './leader-portrait';
|
||||
import { ShipmentQuote } from './shipment-quote';
|
||||
import { reserveShipmentCost } from '@/game/shipment-price';
|
||||
@@ -337,8 +338,9 @@ export function GameTable({
|
||||
!g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
|
||||
const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
|
||||
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
|
||||
const canActivateKarama = (card: Card) =>
|
||||
!g.karamaBlocked && canUseAsKaramaRole(g, me, card);
|
||||
const canSpendKarama = (card: Card, purpose: KullPurpose) =>
|
||||
!kullPurposeBlock(g, purpose) && canUseAsKaramaRole(g, me, card);
|
||||
const canActivateKarama = (card: Card) => canSpendKarama(card, kullSpecialPurpose(me.faction));
|
||||
const traitorBattle = g.battle;
|
||||
const traitorBeneficiary =
|
||||
traitorBattle &&
|
||||
@@ -1949,6 +1951,7 @@ export function GameTable({
|
||||
<PrivateBattlePlan game={g} />
|
||||
{!reactionOwnsControls && g.semutaReaction && <SemutaReaction game={g} act={act} busy={reactionBusy} />}
|
||||
{g.karamaBlocked && <p className="notice" role="status">{g.karamaBlocked}</p>}
|
||||
{g.karamaPurposeBlocks?.map(({purpose, message}) => <p key={purpose} className="notice" role="status">{message}</p>)}
|
||||
{!reactionOwnsControls && <>
|
||||
<NexusCards game={g} act={act} busy={busy} />
|
||||
<Recruits game={g} act={act} busy={busy} />
|
||||
@@ -3663,7 +3666,7 @@ export function GameTable({
|
||||
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available,
|
||||
)}
|
||||
{me.hand
|
||||
?.filter(canActivateKarama)
|
||||
?.filter((card) => canSpendKarama(card, 'auction-free-purchase'))
|
||||
.map((c) => (
|
||||
<div key={c.id}>
|
||||
{actionButton(
|
||||
@@ -5813,6 +5816,8 @@ export function GameTable({
|
||||
: null
|
||||
: g.decision
|
||||
? 'Finish the pending decision before taking an auction card.'
|
||||
: kullPurposeBlock(g, 'auction-free-purchase')
|
||||
? kullPurposeBlock(g, 'auction-free-purchase')
|
||||
: g.phase !== 3 || !g.auction || g.richeseAuction
|
||||
? 'The Karama purchase option needs an ordinary auction in progress.'
|
||||
: (me.hand?.length ?? 0) >= me.handLimit
|
||||
@@ -6226,7 +6231,7 @@ export function GameTable({
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{canActivateKarama(c) &&
|
||||
{canSpendKarama(c, 'shipment-price') &&
|
||||
g.phase === 5 &&
|
||||
g.active &&
|
||||
(g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && (
|
||||
|
||||
@@ -2,6 +2,64 @@
|
||||
|
||||
Source audit: 2026-09-06; opt-in preview contract updated 30 September 2026. This is an engineering and product-policy record, not a rules-completion claim. The source audit in [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md) remains applicable. The user-approved counter and BG choices supersede the original proposal; publisher-source uncertainty and full expansion gates remain.
|
||||
|
||||
## Advanced effect scope — 18 October 2026
|
||||
|
||||
New Advanced attempts use the supplied rulebook’s specific-effect scope;
|
||||
[exact source and duration interpretation](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026).
|
||||
Basic and previously unstamped pending/settled frames retain all-purpose phase
|
||||
scope. The older phase-wide descriptions below apply to that path.
|
||||
|
||||
`game/kull-purpose.ts` defines finite semantic keys and readable labels.
|
||||
`KullPhaseRestriction.purpose` and the signed pending frame’s optional `purpose`
|
||||
select the new scope. Omission retains the original meaning. Four-argument
|
||||
`kullBlocksKarama` asks only about an all-purpose ban; a fifth key checks that
|
||||
ban or the matching effect. Active restriction copies retain keys and exact
|
||||
same-effect duplicates reject. Source feasibility and final settlement use the
|
||||
same restriction factory; all committed activations check the intended effect.
|
||||
|
||||
`GameView.karamaBlocked` remains only the all-purpose message. Its own
|
||||
`karamaPurposeBlocks` lists `{purpose,label,message}` for active scoped effects.
|
||||
`kullReaction` adds public `scope: effect | phase` and a readable effect name,
|
||||
without exposing the private intent payload. Response controls, auction payment,
|
||||
shipment pricing and faction specials consult the same scope. No restriction
|
||||
removes physical ownership or legal nonactivation transfers/discards.
|
||||
|
||||
Genuine-action fixtures select the unopened original deal and unopened Baliset/
|
||||
Spice positions explicitly, then use real setup, normal1 purchase, CHOAM1
|
||||
shipment, Kull, normal desert shipment and ground movement. No postdeal hand,
|
||||
wallet, force or clock is installed. Advanced can use the retained original to
|
||||
cancel native Baliset in the same phase; Basic keeps its broad ban. Separate
|
||||
controlled intent tests check Emperor variants, purchase/payment and original
|
||||
unstamped semantics. These are not full combinations or persistence assurance.
|
||||
|
||||
Frozen **12 files111/111**, types/lint/build and two original-action/rendered-table
|
||||
programmes pass. Basic uses62 source+8 continuation actions; Advanced70+14.
|
||||
Actual Baliset1 purchase, CHOAM1 city shipment and normal desert2 shipment lead
|
||||
to the real Baliset response. Advanced’s retained original cancels it, while
|
||||
Basic’s unavailable control stays disabled. All four minimal policies legally
|
||||
pass that response; no strategy calibration. Controlled intent tests also prove
|
||||
shared purchase/payment and Emperor variants, different special/cancellation
|
||||
use, and the original unstamped frame behavior.
|
||||
|
||||
The source-bound aggregate is outside Git at
|
||||
`~/.local/share/dune/checkpoints/20261018-kull-purpose/frozen-integrated-final/report.json`:
|
||||
base `b82441c834ebf6c59a3b09a22a4e8821f4b7ccf8`, source tree
|
||||
`7624106ac78c036d15c74ef3a226b4dfea52e51cfebe7b36f378c10736fea177`.
|
||||
All passed checks bind that exact source. The first union exposed two fixture
|
||||
expectations (forced Harkonnen return and an obsolete Fremen Cunning fence);
|
||||
those repaired cases passed before the frozen union. The initial native case
|
||||
also corrected a final active-seat assumption at CHOAM’s real phase-end market.
|
||||
Types caught one nullable fixture guard. The external table smoke’s Basic
|
||||
expectation and enabled-button matcher were then corrected; only that smoke was
|
||||
rerun on the unchanged tree, preserving the earlier successful check reports.
|
||||
Only checkpoint prose/reference evidence changed afterward.
|
||||
|
||||
Recovery schema expectations were maintained but SQL assurance was not run.
|
||||
Runtime table rendering uses framework aliases/CSS stubs and does not establish
|
||||
visual/browser/deployed behavior. Original Nexus/alias programmes, wider
|
||||
combinations, full modes and comprehensive assurance remain due. No saved-game/
|
||||
database/reset/restart or deployment operation occurred.
|
||||
|
||||
## Authoritative boundary
|
||||
|
||||
[GF9 CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf), printed p. 7: Kull reacts to an attempted Karama play and prevents that player from playing Karama for the phase. Printed p. 12 permits Karama to prevent CHOAM's special-effect discard of a Worthless card during that phase. These clauses establish both directions of interaction, but do not specify a nested-response protocol.
|
||||
@@ -65,7 +123,7 @@ The pure [Kull module](../game/choam-kull.ts) owns `KullPhaseRestriction`, `acti
|
||||
|
||||
The original and counter cards have different obligations. The original stays reserved until Kull settles; ordinary counter eligibility excludes that exact physical ID. A different printed Karama or eligible BG substitution may counter while Kull is pending. A BG counter's own conversion response must not overwrite the original intent or its suspended response.
|
||||
|
||||
The consumer interface is `GameView.kullReaction`: `null` or `{ event, player, target, intent, plays, canDecline, blocked }`. The old `cards` array is removed. Each private play is `{ source: 'printed' | 'nexus', effect: 'kull', card: Card, event: string, blocked: string | null }`, separating the real selected fuel from the effect and source. It is present during the offer stage; the counter stage uses existing `responseControls`. `player` is the public CHOAM reactor, `target` the attempted activating player, and `intent` the ordinary purpose kind or generic `special` label, never a special selection. All viewers see the same event/actors/intent; only CHOAM receives its plays and `canDecline: true`; others get empty plays. The offer remains declinable with no legal cost. Countering reuses ordinary `card/mode: cancel` or `passResponse`. Never project special choices, saved secrets, signatures, funding or hidden hands, and never derive public opportunity timing from a secret Nexus or cost.
|
||||
The consumer interface is `GameView.kullReaction`: `null` or `{ event, player, target, intent, scope, effect, plays, canDecline, blocked }`. The old `cards` array is removed. Each private play is `{ source: 'printed' | 'nexus', effect: 'kull', card: Card, event: string, blocked: string | null }`, separating the real selected fuel from the effect and source. It is present during the offer stage; the counter stage uses existing `responseControls`. `player` is the public CHOAM reactor, `target` the attempted activating player, and `intent` the ordinary purpose kind or generic `special` label, never a special selection. All viewers see the same event/actors/intent; only CHOAM receives its plays and `canDecline: true`; others get empty plays. The offer remains declinable with no legal cost. Countering reuses ordinary `card/mode: cancel` or `passResponse`. Never project special choices, saved secrets, signatures, funding or hidden hands, and never derive public opportunity timing from a secret Nexus or cost.
|
||||
|
||||
`GameView.kullCounterEvent` is `null` or the same opaque public attempt event while the distinct-counter stage owns control, including a BG `worthlessKarama` overlay. Controls and bots use it only for precedence; legal physical choices still come from native `responseControls`. It reveals no original card, Kull cost or saved response payload. `GameView.karamaBlocked` is the current viewer's stamped activation-block reason or `null`, separate from a pending declaration; `kullPreview` supplies the shared interception warning and `nexusKullPreview` identifies the new source capability.
|
||||
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
|
||||
Checked 2026-09-07 for classic GF9 Dune and CHOAM & Richese; preview policy and source-aware Nexus cost boundary updated 30 September 2026. This supplements [CHOAM_KULL_DESIGN.md](CHOAM_KULL_DESIGN.md), [native CHOAM Cunning](NEXUS_CHOAM_RULES.md) and the Kull section of [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md). The source audit itself did not change runtime behavior; later opt-in development implementations follow the explicit selections below. This update does not claim a newly fetched publisher ruling.
|
||||
|
||||
## Advanced specific-effect scope — 18 October 2026
|
||||
|
||||
The authorized supplied Advanced rulebook, physical29/logical27, describes
|
||||
preventing the attempted Karama for a **specific effect**, with a reference to
|
||||
physical16/logical14. It omits the publisher CHOAM rule’s phase-wide wording.
|
||||
The referenced Karama rules distinguish advantage cancellation, shipment pricing,
|
||||
free Treachery purchase and a faction special. This is a supplied-source cutover,
|
||||
not a newly located GF9 clarification. Basic retains the publisher-wide scope.
|
||||
|
||||
New Advanced Kull attempts bind the interrupted semantic purpose. Successful
|
||||
settlement restricts that purpose for the remainder of the turn/phase; another
|
||||
Karama purpose remains usable. **Effect-specific scope is sourced; phase expiry
|
||||
is provisional**, composed with the publisher duration under the 14 October
|
||||
choose-recommended policy. No text was found that specifies a replacement expiry.
|
||||
|
||||
| Attempt | Bound effect |
|
||||
| --- | --- |
|
||||
| Guild-price shipment | `shipment-price`, independent of beneficiary, amount and route |
|
||||
| Immediate free purchase / winning Karama payment | Shared `auction-free-purchase` |
|
||||
| Faction special | `special:<actual faction>`; Emperor force/leader and Guild route variants share one key |
|
||||
| Cancel a native ability | Canonical response power; native/enhanced Prescience and advisor conversion share keys |
|
||||
| Cancel a CHOAM Worthless ability | The actual effect, so Baliset and Kull are distinct |
|
||||
| Cancel special-force strength | The affected faction’s strength advantage |
|
||||
|
||||
Guild price and cross/return permission remain distinct powers; so do advisor
|
||||
accompaniment/conversion and own/allied worm protection. No key contains a
|
||||
physical card, hidden plan, price, force quantity, route or recipient. Substituted
|
||||
and printed Karama for the same effect share the restriction.
|
||||
|
||||
Each new Advanced pending frame signs its purpose before costs; original CHOAM
|
||||
parents are derived through the saved original context, not the temporary Kull
|
||||
counter declaration. Existing unstamped pending frames and restrictions retain
|
||||
the original all-purpose scope. No played save is rewritten. Own table guidance
|
||||
names scoped restrictions; unrelated controls and legal minimal policies remain
|
||||
available. Neutral offers still appear without an eligible Kull cost.
|
||||
|
||||
The explicit distinct-counter, BG-before-conversion and **Defer interaction**
|
||||
overbid selections remain. This does not open any profile, module, full-faction
|
||||
or publication gate. [Current implementation/proof boundary](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026)
|
||||
tracks the connected function separately from historical broad assurance.
|
||||
|
||||
## Native fixture integration maintenance — 16 October 2026
|
||||
|
||||
The BG Semuta-cost integration selection exposed three stale Nexus Kull
|
||||
|
||||
@@ -16,6 +16,32 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Advanced Kull effect scope — 18 October. Prototyped / Partial.** New
|
||||
Advanced attempts adopt the supplied specific-effect wording. Basic and
|
||||
unstamped older pending/settled restrictions remain all-purpose. Semantic keys
|
||||
keep another effect available, normalize purchase/payment and native variants,
|
||||
and bind the original CHOAM parent rather than its temporary Kull counter.
|
||||
Retaining phase expiry is explicitly provisional; prior counter/BG/deferred
|
||||
winning-overbid choices remain.
|
||||
|
||||
Two disjoint implementation lanes, bounded source review and owner integration
|
||||
pass **12 files111/111**, types/lint/build and two original-action/rendered-table
|
||||
programmes. Selected unopened deal/auction/Spice positions use62 Basic/70
|
||||
Advanced source actions plus8/14 continuations: actual Baliset1 purchase,
|
||||
CHOAM1 city shipment, Kull, normal desert2 shipment and real movement. Advanced
|
||||
cancels Baliset with the retained original; Basic keeps it unavailable. Four
|
||||
minimal policies legally pass the different-effect response; no tuning.
|
||||
|
||||
Feedback corrected fixture assumptions about automatic Harkonnen return,
|
||||
CHOAM’s phase-end market and already-supported Fremen Cunning, plus one nullable
|
||||
fixture guard. The external smoke was repaired to distinguish disabled actions
|
||||
from enabled ones and CSS classes from actual attributes; only that smoke was
|
||||
rerun after types/lint/build, against identical source. Recovery expectations
|
||||
were updated without running deferred SQL assurance. Original Nexus/alias/wider
|
||||
combinations, full modes, final assurance and browser/deployed acceptance remain
|
||||
open. No saved-room/database/reset/restart/deployment operation.
|
||||
[Source cutover and exact proof](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026).
|
||||
|
||||
**Recovered Hajr replay — 18 October. Prototyped / Partial.** The deck contains
|
||||
one Hajr. Real paid Box recovery now permits playing that same held card again,
|
||||
counting one extra allowance per accepted use. Advanced needs a fresh completed
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 18 October 2026 — Advanced Kull specific-effect scope
|
||||
|
||||
New Advanced attempts now restrict the semantic Karama effect named by the
|
||||
supplied source; Basic/unstamped frames retain their original broad phase scope.
|
||||
Other effects remain usable through connected engine validation, counters,
|
||||
feasibility, own guidance, human controls and legal bots. Purpose keys normalize
|
||||
purchase/payment and native variants without binding private card/route/amount
|
||||
choices. Phase expiry remains an explicit provisional composition; previous
|
||||
counter/BG/deferred-overbid selections and preview/publication gates stay.
|
||||
|
||||
Two disjoint lanes plus bounded source review and owner integration pass12-file
|
||||
111/111, types/lint/build and two real-action/rendered-table programmes.
|
||||
Selected unopened deal/auction/Spice order uses62 Basic/70 Advanced source plus
|
||||
8/14 continuation actions, with actual Baliset1 purchase and paid shipments.
|
||||
Advanced’s retained original cancels Baliset; Basic keeps cancellation blocked.
|
||||
Four minimal policies legally pass. Feedback corrected stale/automatic fixture
|
||||
expectations and nullable typing; the external enabled-control matcher was
|
||||
repaired and rerun alone on the same verified source. No code changed afterward.
|
||||
[Canonical source, exact proof and limits](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026).
|
||||
Recovery schema assertions were maintained without running deferred SQL audits.
|
||||
Original Nexus/alias programmes, wider combinations, full modes/assurance and
|
||||
browser/deployed acceptance stay open. No saved-room/reset/restart/deployment.
|
||||
|
||||
## 18 October 2026 — Recovered physical Hajr replay
|
||||
|
||||
The original inventory has one Hajr; genuine paid Box retrieval now allows that
|
||||
|
||||
@@ -76,7 +76,12 @@ The API uses `kullReaction.plays`, not a bare `cards` list. Each private choice
|
||||
|
||||
**Source is required on every Use, including printed Kull.** There is no omitted-source fallback and no client-selected effect, target, price or Nexus ID. The generic any-time Nexus action does not play Kull: the validated pending Karama opportunity is required. Reject stale/foreign events, malformed costs or broken saved ownership before costs or writes.
|
||||
|
||||
The accepted-declaration Nexus spend and retained pending fuel reuse the existing native Cunning application convention. E3 establishes that a played Nexus is discarded; it does not prescribe this internal transaction boundary or a Kull nested-response priority. A distinct eligible counter acts before the stamped phase ban; the original remains reserved. Success discards fuel once, keeps the original printed/BG card unplayed and special once-use unspent, and restricts its activating player for the current turn and phase. Prevention retains fuel under the existing exact-cost phase policy, leaves Nexus spent and resumes the saved original once. Decline spends neither cost and resumes it once. BG interception is before conversion; the original ordinary conversion and its FAQ disposal rules resume only after decline or prevention.
|
||||
New Advanced Kull attempts adopt the supplied specific-effect scope with
|
||||
provisional phase expiry. Basic and unstamped older attempts/restrictions retain
|
||||
phase-wide scope. Cunning changes fuel, not the selected effect’s scope. See the
|
||||
[18 October source cutover](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026).
|
||||
|
||||
The accepted-declaration Nexus spend and retained pending fuel reuse the existing native Cunning application convention. E3 establishes that a played Nexus is discarded; it does not prescribe this internal transaction boundary or a Kull nested-response priority. A distinct eligible counter acts before the stamped phase ban; the original remains reserved. Success discards fuel once, keeps the original printed/BG card unplayed and special once-use unspent, and restricts its activating player for the current turn and phase using the Basic/legacy broad or new Advanced effect scope above. Prevention retains fuel under the existing exact-cost phase policy, leaves Nexus spent and resumes the saved original once. Decline spends neither cost and resumes it once. BG interception is before conversion; the original ordinary conversion and its FAQ disposal rules resume only after decline or prevention.
|
||||
|
||||
## Pure saved receipt and integration contract
|
||||
|
||||
|
||||
@@ -843,6 +843,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
|
||||
| Duke Vidal revival custody | The user selected living set-aside custody after Ecaz revives Duke; a separate Ambassador/Nexus acquisition grants battle control. Only Ecaz may revive him, including Ghola. Native repeated returns now use the separately labelled provisional identity-cohort policy, not a new publisher example or user number. Captured scope and broad combinations remain separately bounded. | [Selected custody](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026), [current cohort policy](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026) |
|
||||
| Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup now have a bounded CHOAM/Richese-deck battle runtime; combined modules and full Richese games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) |
|
||||
| Advanced Kull specific effect | Supplied physical29/logical27 restricts the attempted Karama for a specific effect, unlike publisher Basic phase-wide wording. New signed Advanced attempts bind semantic purpose; purchase/payment and native special variants normalize, as do enhanced Prescience/advisor conversion. CHOAM effects and special-force faction powers stay distinct. Retaining turn/phase expiry is explicit provisional composition under the 14 October policy, not supplied wording. Legacy unstamped pending/restriction frames retain broad scope. Prior distinct counter, BG-before-conversion and deferred overbid selections stay. | [Source cutover and keys](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026), [connected implementation and limits](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026) |
|
||||
| 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) |
|
||||
| Semuta / native Basic Terror | Fresh local `semuta` additionally admits Basic Richese/Moritani plus classics, CHOAM/Ecaz decks and no optional modules. Private Sabotage/Robbery overflow after clean ordinary shipment/movement arrivals uses the existing neutral all-seat response and committed-only face access. Original victim/draw, removed token, payment, forces and optional gift remain original. Other arrival parents, Advanced Moritani and wider family mixtures remain implementation boundaries, not printed Semuta prohibitions. | [Source and bounded evidence](SEMUTA_SOURCE_UPDATE_20260907.md), [native suffix](TERROR_DISCARD_CONTINUATIONS.md) |
|
||||
|
||||
+4
-3
@@ -77,6 +77,7 @@ import {
|
||||
import { ownedTech } from './tech-tokens';
|
||||
import type { StrongholdProgress } from './victory-progress';
|
||||
import { canUseAsKarama } from './karama';
|
||||
import { kullPurposeBlock, kullSpecialPurpose } from './kull-purpose';
|
||||
import {
|
||||
canUseAsKaramaRole,
|
||||
shrineTruthtranceBotAction,
|
||||
@@ -1410,7 +1411,7 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (
|
||||
!g.battle &&
|
||||
!g.karamaBlocked &&
|
||||
!kullPurposeBlock(g, kullSpecialPurpose(me.faction)) &&
|
||||
g.choamCashIn?.karamas.length &&
|
||||
(me.spice ?? 0) < (level === 0 ? 2 : 5)
|
||||
) {
|
||||
@@ -1457,7 +1458,7 @@ function policyActions(g: GameView): Action[] {
|
||||
const sapho = saphoAction(g);
|
||||
if (sapho) return [sapho];
|
||||
const specialCard =
|
||||
!g.karamaBlocked &&
|
||||
!kullPurposeBlock(g, kullSpecialPurpose(me.faction)) &&
|
||||
!g.choamMarket &&
|
||||
(!g.revival.pending ||
|
||||
(me.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop')) &&
|
||||
@@ -2849,7 +2850,7 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (d.kind === 'auctionPayment') {
|
||||
const karama = me.hand?.find((c) =>
|
||||
!g.karamaBlocked && canUseAsKaramaRole(g, me, c),
|
||||
!kullPurposeBlock(g, 'auction-free-purchase') && canUseAsKaramaRole(g, me, c),
|
||||
);
|
||||
const use =
|
||||
!!karama &&
|
||||
|
||||
+12
-6
@@ -1,9 +1,12 @@
|
||||
import type { Card } from './cards';
|
||||
import { isKullPurpose, type KullPurpose } from './kull-purpose';
|
||||
|
||||
export type KullPhaseRestriction = Readonly<{
|
||||
player: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
/** Absent preserves the original Basic/legacy all-purpose phase ban. */
|
||||
purpose?: KullPurpose;
|
||||
}>;
|
||||
|
||||
export type KullContext = Readonly<{
|
||||
@@ -41,7 +44,8 @@ function plainRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
function validRestriction(value: unknown): value is KullPhaseRestriction {
|
||||
return plainRecord(value) &&
|
||||
Object.keys(value).sort().join(',') === 'phase,player,turn' &&
|
||||
(Object.keys(value).sort().join(',') === 'phase,player,turn' ||
|
||||
Object.keys(value).sort().join(',') === 'phase,player,purpose,turn' && isKullPurpose(value.purpose)) &&
|
||||
identifier(value.player) && stamp(value.turn, value.phase);
|
||||
}
|
||||
|
||||
@@ -62,8 +66,8 @@ export function activeKullRestrictions(
|
||||
return (restrictions ?? [])
|
||||
.filter(restriction => validRestriction(restriction) &&
|
||||
restriction.turn === turn && restriction.phase === phase)
|
||||
.map(({ player, turn: savedTurn, phase: savedPhase }) =>
|
||||
({ player, turn: savedTurn, phase: savedPhase }));
|
||||
.map(({ player, turn: savedTurn, phase: savedPhase, purpose }) =>
|
||||
({ player, turn: savedTurn, phase: savedPhase, ...(purpose !== undefined ? { purpose } : {}) }));
|
||||
}
|
||||
|
||||
export function kullBlocksKarama(
|
||||
@@ -71,10 +75,12 @@ export function kullBlocksKarama(
|
||||
turn: number,
|
||||
phase: number,
|
||||
player: string,
|
||||
purpose?: KullPurpose,
|
||||
): boolean {
|
||||
return identifier(player) && stamp(turn, phase) &&
|
||||
return identifier(player) && stamp(turn, phase) && (purpose === undefined || isKullPurpose(purpose)) &&
|
||||
!!restrictions?.some(restriction => validRestriction(restriction) &&
|
||||
restriction.player === player && restriction.turn === turn && restriction.phase === phase);
|
||||
restriction.player === player && restriction.turn === turn && restriction.phase === phase &&
|
||||
(restriction.purpose === undefined || purpose !== undefined && restriction.purpose === purpose));
|
||||
}
|
||||
|
||||
/** Saved history may expire, but malformed, duplicate or future bans are not history. */
|
||||
@@ -91,7 +97,7 @@ export function validateKullPhaseRestrictions(
|
||||
restriction.turn > context.turn ||
|
||||
(restriction.turn === context.turn && restriction.phase > context.phase))
|
||||
throw new Error('Kull Wahad has lost its phase restriction history.');
|
||||
const key = JSON.stringify([restriction.player, restriction.turn, restriction.phase]);
|
||||
const key = JSON.stringify([restriction.player, restriction.turn, restriction.phase, restriction.purpose ?? null]);
|
||||
if (seen.has(key)) throw new Error('Kull Wahad has duplicate phase restrictions.');
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
+65
-25
@@ -508,6 +508,7 @@ import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionS
|
||||
import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
|
||||
import { cashInCards } from './choam-karama';
|
||||
import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullNexusCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull';
|
||||
import { kullCancellationPurpose, kullSpecialPurpose, kullPurposeLabel, type KullPurpose } from './kull-purpose';
|
||||
import { quoteRicheseBetrayalInvoice, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal';
|
||||
import { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
|
||||
import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy';
|
||||
@@ -1302,6 +1303,8 @@ export type Game = {
|
||||
turn: number;
|
||||
phase: number;
|
||||
form: 'printed' | 'substitution';
|
||||
/** New Advanced attempts restrict this effect; absent preserves original phase scope. */
|
||||
purpose?: KullPurpose;
|
||||
stage: 'offer' | 'counter';
|
||||
selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string };
|
||||
intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent };
|
||||
@@ -13769,10 +13772,29 @@ function nextAuction(g: Game) {
|
||||
currentAuctionContinuationQuote(g, { kind: 'next' }),
|
||||
);
|
||||
}
|
||||
/** Physical custody only: promise feasibility remains conservatively checked at commit. */
|
||||
function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null {
|
||||
if (g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id))
|
||||
return 'Kull Wahad prevents your Karama activations for this phase.';
|
||||
/** Semantic effects exclude card identity, amounts, beneficiaries and route choices. */
|
||||
function karamaUsePurpose(g: Game, use: KaramaUse): KullPurpose {
|
||||
if (use.kind === 'shipment') return 'shipment-price';
|
||||
if (use.kind !== 'cancel') return 'auction-free-purchase';
|
||||
const response = use.response;
|
||||
return kullCancellationPurpose(response.kind,
|
||||
response.kind === 'choamWorthless' ? g.pendingChoamWorthless?.effect
|
||||
: response.kind === 'eliteStrength' ? getPlayer(g, response.owner).faction : undefined);
|
||||
}
|
||||
function kullIntentPurpose(g: Game, intent: NonNullable<Game['pendingKull']>['intent']): KullPurpose {
|
||||
return intent.kind === 'ordinary' ? karamaUsePurpose(g, intent.use)
|
||||
: kullSpecialPurpose(getPlayer(g, intent.use.owner).faction);
|
||||
}
|
||||
function kullKaramaBlock(g: Game, p: Player, purpose?: KullPurpose): string | null {
|
||||
if (!g.kullPreview || !kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id, purpose)) return null;
|
||||
return kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id)
|
||||
? 'Kull Wahad prevents your Karama activations for this phase.'
|
||||
: `Kull Wahad prevents ${kullPurposeLabel(purpose!)} for this phase. Other Karama effects remain available.`;
|
||||
}
|
||||
/** Physical custody plus the intended effect; promise feasibility is checked at commit. */
|
||||
function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpose): string | null {
|
||||
const kullBlock = kullKaramaBlock(g, p, purpose);
|
||||
if (kullBlock) return kullBlock;
|
||||
if (g.pendingKull?.stage === 'counter' &&
|
||||
(g.pendingKull.card === card.id || g.pendingChoamWorthless?.card === card.id ||
|
||||
g.pendingKull.worthless?.card === card.id))
|
||||
@@ -13797,12 +13819,12 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null {
|
||||
return 'A Worthless conversion is already pending.';
|
||||
return null;
|
||||
}
|
||||
function karamaCard(g: Game, p: Player, id?: unknown) {
|
||||
function karamaCard(g: Game, p: Player, id?: unknown, purpose?: KullPurpose) {
|
||||
// Explicit selections retain the specific custody error from spendKarama.
|
||||
const available = p.hand.filter(
|
||||
(c) =>
|
||||
canUseAsKaramaRole(g, p, c) &&
|
||||
(id === undefined ? !karamaSpendingBlock(g, p, c) : c.id === id),
|
||||
(id === undefined ? !karamaSpendingBlock(g, p, c, purpose) : c.id === id),
|
||||
);
|
||||
return available.find((c) => c.effect === 'karama') ?? available[0];
|
||||
}
|
||||
@@ -13815,7 +13837,7 @@ function responseCancelCards(
|
||||
if (g.pendingKull?.stage === 'counter')
|
||||
return kullCounterCards(p.hand, physicalTreacheryCards(g), g.pendingKull.card,
|
||||
card => {
|
||||
if (!canUseAsKaramaRole(g, p, card) || karamaSpendingBlock(g, p, card)) return false;
|
||||
if (!canUseAsKaramaRole(g, p, card) || karamaSpendingBlock(g, p, card, karamaUsePurpose(g, {kind:'cancel', response}))) return false;
|
||||
try {
|
||||
return assertKaramaPromiseFeasibility(g, p, { kind: 'cancel', response }, card);
|
||||
} catch (error) {
|
||||
@@ -13827,7 +13849,7 @@ function responseCancelCards(
|
||||
// A physically spendable card whose Truthtrance feasibility is not proven
|
||||
// remains a possible choice; the authoritative action still checks promises.
|
||||
return p.hand
|
||||
.filter((card) => !karamaSpendingBlock(g, p, card))
|
||||
.filter((card) => !karamaSpendingBlock(g, p, card, karamaUsePurpose(g, {kind:'cancel', response})))
|
||||
.map((card) => card.id);
|
||||
}
|
||||
/** The same conversion can be suspended by an independent private interaction. */
|
||||
@@ -14445,6 +14467,8 @@ function validateKaramaUse(
|
||||
use: KaramaUse,
|
||||
spendingCard?: Card,
|
||||
) {
|
||||
const kullBlock = kullKaramaBlock(g, p, karamaUsePurpose(g, use));
|
||||
requireRule(!kullBlock, kullBlock ?? 'Kull Wahad prevents this Karama effect.');
|
||||
if (use.kind === 'cancel') {
|
||||
requireRule(
|
||||
use.response && use.response.owner !== p.id,
|
||||
@@ -14652,6 +14676,10 @@ function assertKaramaPromiseFeasibility(
|
||||
return true;
|
||||
}
|
||||
type KullFrame = NonNullable<Game['pendingKull']>;
|
||||
function kullSettledRestriction(g: Game, frame: KullFrame): KullPhaseRestriction {
|
||||
return { player: frame.owner, turn: g.turn, phase: g.phase,
|
||||
...(frame.purpose === undefined ? {} : { purpose: frame.purpose }) };
|
||||
}
|
||||
// Independent printed descriptors, assembled once rather than trusting a saved
|
||||
// hand to define its own identity or rebuilding the physical deck per quote.
|
||||
const kullCanonicalCards: Readonly<Record<string, Card>> =
|
||||
@@ -14665,7 +14693,7 @@ function kullCostRetainsAuctionPayment(g: Game, p: Player, card: Card, physical:
|
||||
return !g.auction || g.auction.bidder !== p.id || auctionSpicePaymentFunded(g, p) ||
|
||||
p.hand.some(held => held.id !== card.id && canonicalKullCard(held) &&
|
||||
physical.reduce((count, candidate) => count + Number(candidate.id === held.id), 0) === 1 &&
|
||||
!karamaSpendingBlock(g, p, held));
|
||||
!karamaSpendingBlock(g, p, held, 'auction-free-purchase'));
|
||||
}
|
||||
function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'printed' | 'nexus'): boolean {
|
||||
const source = g.pendingKull ? kullSource(g, g.pendingKull) : g;
|
||||
@@ -14686,7 +14714,7 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin
|
||||
if (g.pendingKull)
|
||||
projected.kullRestrictions = [
|
||||
...activeKullRestrictions(projected.kullRestrictions, projected.turn, projected.phase),
|
||||
{ player: g.pendingKull.owner, turn: projected.turn, phase: projected.phase },
|
||||
kullSettledRestriction(projected, g.pendingKull),
|
||||
];
|
||||
}
|
||||
projected.response = null;
|
||||
@@ -14731,7 +14759,7 @@ function kullSignature(g: Game, frame: KullFrame): string {
|
||||
return JSON.stringify({
|
||||
event: frame.event, player: frame.player, owner: frame.owner, card: frame.card,
|
||||
turn: frame.turn, phase: frame.phase, form: frame.form, intent: frame.intent,
|
||||
selection: frame.selection,
|
||||
selection: frame.selection, purpose: frame.purpose,
|
||||
controls: nullentropyParentSignature(source, frame.resume, frame.event),
|
||||
status: source.status, advanced: source.advanced, active: source.active,
|
||||
order: source.order, ready: source.ready, hajr: source.hajr, choamMarket: source.choamMarket,
|
||||
@@ -14790,6 +14818,8 @@ function kullIntegrity(g: Game): void {
|
||||
requireRule(frame.signature === kullSignature(g, frame),
|
||||
'The saved Kull attempt no longer matches its original controls and opportunity.');
|
||||
const source = kullSource(g, frame), owner = getPlayer(source, frame.owner);
|
||||
requireRule(frame.purpose === undefined || (g.advanced && frame.purpose === kullIntentPurpose(source, frame.intent)),
|
||||
'The Advanced Kull restriction no longer matches the original Karama effect.');
|
||||
if (frame.worthless) {
|
||||
const parentOwner = getPlayer(source, frame.worthless.owner);
|
||||
requireRule(kullNexusCostCards(parentOwner.hand, physicalTreacheryCards(g))
|
||||
@@ -14800,7 +14830,7 @@ function kullIntegrity(g: Game): void {
|
||||
requireRule(card, 'The original attempted Karama must remain in its owner’s hand.');
|
||||
requireRule(frame.form === (card.kind === 'worthless' ? 'substitution' : 'printed'),
|
||||
'The Kull attempt no longer matches its original physical card form.');
|
||||
requireRule(!karamaSpendingBlock(source, owner, card), 'The original Karama activation is no longer legal.');
|
||||
requireRule(!karamaSpendingBlock(source, owner, card, kullIntentPurpose(source, frame.intent)), 'The original Karama activation is no longer legal.');
|
||||
if (frame.intent?.kind === 'ordinary') {
|
||||
validateKaramaUse(source, owner, frame.intent.use, card);
|
||||
requireRule(assertKaramaPromiseFeasibility(source, owner, frame.intent.use, card),
|
||||
@@ -14860,6 +14890,7 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int
|
||||
player: choam.id, owner: p.id, card: card.id,
|
||||
turn: g.turn, phase: g.phase, form: card.kind === 'worthless' ? 'substitution' : 'printed',
|
||||
stage: 'offer', intent: structuredClone(intent), resume: structuredClone(resume),
|
||||
...(g.advanced ? { purpose: kullIntentPurpose(g, intent) } : {}),
|
||||
worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '',
|
||||
};
|
||||
// Payment dispatch removed this control before entering its native branch.
|
||||
@@ -14918,7 +14949,7 @@ function decideKull(g: Game, p: Player, action: Action): void {
|
||||
log(g, `${p.name} declared Kull Wahad. Only a different Karama can prevent it.`);
|
||||
}
|
||||
function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered = false) {
|
||||
const blocked = karamaSpendingBlock(g, p, card);
|
||||
const blocked = karamaSpendingBlock(g, p, card, karamaUsePurpose(g, use));
|
||||
requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.');
|
||||
validateKaramaUse(g, p, use, card);
|
||||
const promiseProven = assertKaramaPromiseFeasibility(g, p, use, card);
|
||||
@@ -15402,7 +15433,7 @@ function finishAutomaticDecision(g: Game): boolean {
|
||||
// split or consume a card on behalf of a player, including a BG conversion.
|
||||
if (
|
||||
auctionSpicePaymentFunded(g, p) &&
|
||||
!p.hand.some((card) => !karamaSpendingBlock(g, p, card)) &&
|
||||
!p.hand.some((card) => !karamaSpendingBlock(g, p, card, 'auction-free-purchase')) &&
|
||||
!emperorNexusUniformAuctionPayment(g)
|
||||
) {
|
||||
g.decision = null;
|
||||
@@ -15414,7 +15445,7 @@ function finishAutomaticDecision(g: Game): boolean {
|
||||
}
|
||||
function recoverAuctionPayment(g: Game, p: Player) {
|
||||
const a = g.auction!;
|
||||
if (a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) || karamaCard(g, p)) {
|
||||
if (a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) || karamaCard(g, p, undefined, 'auction-free-purchase')) {
|
||||
g.decision = { kind: 'auctionPayment', player: p.id };
|
||||
return;
|
||||
}
|
||||
@@ -26159,9 +26190,11 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
const used = discard(g, choam, pending.card);
|
||||
finishNexusChoam(g,pending,'complete');
|
||||
if (pending.effect === 'kull') {
|
||||
const restriction = kullSettledRestriction(g, g.pendingKull!);
|
||||
g.kullRestrictions = [...activeKullRestrictions(g.kullRestrictions, g.turn, g.phase),
|
||||
{ player: g.pendingKull!.owner, turn: g.turn, phase: g.phase }];
|
||||
log(g, `${getPlayer(g, g.pendingKull!.owner).name} cannot activate Karama for this phase. The attempted card stays held.`);
|
||||
restriction];
|
||||
log(g, `${getPlayer(g, g.pendingKull!.owner).name} cannot activate ${restriction.purpose
|
||||
? kullPurposeLabel(restriction.purpose) : 'Karama'} for this phase. The attempted card stays held.`);
|
||||
resumeKullAttempt(g, true);
|
||||
return;
|
||||
}
|
||||
@@ -29286,7 +29319,7 @@ function expansionSpecialKaramaRule<T>(calculate: () => T): T {
|
||||
}
|
||||
function actualSpecialKaramaCards(g: Game, p: Player) {
|
||||
return p.hand.filter(card => card.kind === 'special' && card.effect === 'karama' &&
|
||||
!karamaSpendingBlock(g, p, card));
|
||||
!karamaSpendingBlock(g, p, card, kullSpecialPurpose(p.faction)));
|
||||
}
|
||||
function ecazSpecialKaramaContext(g: Game, p: Player) {
|
||||
const b = g.battle;
|
||||
@@ -29399,7 +29432,7 @@ export function prepareSpecialKaramaIntent(
|
||||
(c) => c.id === action.card && canUseAsKaramaRole(g, p, c),
|
||||
);
|
||||
requireRule(card, 'Choose a Karama card in your hand.');
|
||||
const activationBlock = karamaSpendingBlock(g, p, card);
|
||||
const activationBlock = karamaSpendingBlock(g, p, card, kullSpecialPurpose(p.faction));
|
||||
requireRule(!activationBlock, activationBlock ?? 'This Karama is committed elsewhere.');
|
||||
const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase };
|
||||
if (p.faction === 'ecaz') {
|
||||
@@ -29421,7 +29454,7 @@ export function prepareSpecialKaramaIntent(
|
||||
return { ...base, kind: 'moritani', source: choice.source, keep: choice.keep, discard: choice.discard };
|
||||
}
|
||||
if (p.faction === 'richese') {
|
||||
const blocked = karamaSpendingBlock(g, p, card);
|
||||
const blocked = karamaSpendingBlock(g, p, card, kullSpecialPurpose(p.faction));
|
||||
requireRule(!blocked, blocked ?? 'This Karama is committed elsewhere.');
|
||||
requireRule(
|
||||
!(
|
||||
@@ -30032,7 +30065,7 @@ function richeseSpecialKaramaView(g: Game, p: Player) {
|
||||
const karamas = p.hand.filter(
|
||||
(c) =>
|
||||
canUseAsKaramaRole(g, p, c) &&
|
||||
!karamaSpendingBlock(g, p, c) &&
|
||||
!karamaSpendingBlock(g, p, c, kullSpecialPurpose(p.faction)) &&
|
||||
!(
|
||||
g.richeseAuction?.source === 'blackMarket' &&
|
||||
!g.currentAuctionSale &&
|
||||
@@ -31136,7 +31169,7 @@ function applyActionInner(
|
||||
id !== response.owner,
|
||||
'Karama cancels another faction’s power.',
|
||||
);
|
||||
const card = karamaCard(g, p, action.card);
|
||||
const card = karamaCard(g, p, action.card, karamaUsePurpose(g, {kind:'cancel', response}));
|
||||
requireRule(card, 'Choose a Karama in your hand.');
|
||||
spendKarama(g, p, card, { kind: 'cancel', response });
|
||||
}
|
||||
@@ -32273,7 +32306,7 @@ function applyActionInner(
|
||||
'Choose spice payment or Karama.',
|
||||
);
|
||||
if (action.karama) {
|
||||
const card = karamaCard(g, p, action.card);
|
||||
const card = karamaCard(g, p, action.card, 'auction-free-purchase');
|
||||
requireRule(card, 'You do not hold a Karama card.');
|
||||
spendKarama(g, p, card, { kind: 'auctionPayment' });
|
||||
} else settleAuction(g, false);
|
||||
@@ -33265,7 +33298,7 @@ function applyActionInner(
|
||||
if (!quotedBid.ok) throw new RuleError(quotedBid.reason);
|
||||
requireRule(!g.kullPreview ||
|
||||
(p.id === byFaction(g, 'choam')?.id &&
|
||||
!kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id)) ||
|
||||
!kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id, 'auction-free-purchase')) ||
|
||||
quotedBid.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
|
||||
'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
|
||||
a.bid = quotedBid.amount;
|
||||
@@ -35325,9 +35358,16 @@ export function viewGame(state: Game, id: string) {
|
||||
kullCounterEvent: g.pendingKull?.stage === 'counter' ? g.pendingKull.event : null,
|
||||
karamaBlocked: g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, id)
|
||||
? 'Kull Wahad prevents your Karama activations for this phase.' : null,
|
||||
karamaPurposeBlocks: (g.kullPreview ? activeKullRestrictions(g.kullRestrictions, g.turn, g.phase) : [])
|
||||
.filter((restriction): restriction is KullPhaseRestriction & {purpose: KullPurpose} =>
|
||||
restriction.player === id && restriction.purpose !== undefined)
|
||||
.map(({purpose}) => ({purpose, label: kullPurposeLabel(purpose),
|
||||
message: kullKaramaBlock(g, me, purpose)!})),
|
||||
kullReaction: g.pendingKull?.stage === 'offer'
|
||||
? { event: g.pendingKull.event, player: g.pendingKull.player, target: g.pendingKull.owner,
|
||||
intent: g.pendingKull.intent.kind === 'ordinary' ? g.pendingKull.intent.use.kind : 'special',
|
||||
scope: g.pendingKull.purpose === undefined ? 'phase' as const : 'effect' as const,
|
||||
effect: kullPurposeLabel(kullIntentPurpose(kullSource(g, g.pendingKull), g.pendingKull.intent)),
|
||||
plays: id === g.pendingKull.player ? kullPlays(g, me) : [],
|
||||
canDecline: id === g.pendingKull.player, blocked: null as string | null }
|
||||
: null,
|
||||
@@ -35404,7 +35444,7 @@ export function viewGame(state: Game, id: string) {
|
||||
? {
|
||||
cards: cashInCards(g, me),
|
||||
karamas: cashInCards(g, me).filter((c) =>
|
||||
canUseAsKaramaRole(g, me, c),
|
||||
canUseAsKaramaRole(g, me, c) && !karamaSpendingBlock(g, me, c, kullSpecialPurpose(me.faction)),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import type { ResponseWindow } from './engine';
|
||||
import { CHOAM_NEXUS_EFFECTS } from './nexus-choam';
|
||||
|
||||
type DetailedResponseKind = 'choamWorthless' | 'eliteStrength';
|
||||
type AliasResponseKind = 'nexusPrescience' | 'nexusAdvisorFlip';
|
||||
type OrdinaryResponseKind = Exclude<ResponseWindow['kind'], DetailedResponseKind | AliasResponseKind>;
|
||||
const worthlessLabels = CHOAM_NEXUS_EFFECTS;
|
||||
type WorthlessPurposeEffect = keyof typeof worthlessLabels;
|
||||
|
||||
/** Purpose records contain semantic public effects, never a hidden card,
|
||||
* amount, force count, route, recipient or native variant declaration. */
|
||||
export type KullPurpose =
|
||||
| 'shipment-price'
|
||||
| 'auction-free-purchase'
|
||||
| `special:${FactionId}`
|
||||
| `cancel:${OrdinaryResponseKind}`
|
||||
| `cancel:choamWorthless:${WorthlessPurposeEffect}`
|
||||
| `cancel:eliteStrength:${FactionId}`;
|
||||
|
||||
/** Exhaustive against the engine's typed public response kinds. New response
|
||||
* families must receive an explicit purpose rather than an arbitrary suffix. */
|
||||
const cancellationLabels = {
|
||||
moritaniPlacement: 'Moritani Terror placement',
|
||||
ecazPlacement: 'Ecaz Ambassador placement',
|
||||
ecazCollection: 'Ecaz collection',
|
||||
ecazOccupy: 'Ecaz Occupy',
|
||||
moritaniAlliance: 'Moritani alliance',
|
||||
moritaniDuke: 'the Moritani Duke',
|
||||
moritaniRetention: 'Moritani card retention',
|
||||
voice: 'the Voice',
|
||||
prescience: 'Prescience',
|
||||
advisor: 'a spiritual advisor shipment',
|
||||
emperorIncome: 'Emperor income',
|
||||
richesePurchaseIncome: 'Richese purchase income',
|
||||
richeseGift: 'a Richese gift',
|
||||
richeseNoField: 'Richese No-Field use',
|
||||
harkonnenBonus: 'the Harkonnen bonus card',
|
||||
harkonnenTraitor: 'the Harkonnen allied traitor call',
|
||||
guildIncome: 'Guild income',
|
||||
guildRate: 'the Guild shipping rate',
|
||||
guildTransportPermission: 'Guild transport permission',
|
||||
emperorGift: 'the Emperor gift',
|
||||
emperorRevival: 'Emperor revival',
|
||||
stormPeek: 'the storm peek',
|
||||
stormProtection: 'storm protection',
|
||||
wormPlacement: 'additional worm placement',
|
||||
wormSurvival: 'worm survival',
|
||||
wormAllyProtection: 'allied worm protection',
|
||||
atreidesAuction: 'Atreides auction inspection',
|
||||
atreidesSpice: 'Atreides Spice Deck inspection',
|
||||
guildTiming: 'Guild timing',
|
||||
kwisatz: 'Kwisatz Haderach',
|
||||
capture: 'Harkonnen leader capture',
|
||||
fremenSupport: 'Fremen support',
|
||||
advisorFlip: 'advisor conversion',
|
||||
nexusSardaukar: 'Nexus Sardaukar strength',
|
||||
nexusFremenCunning: 'Fremen Nexus Cunning',
|
||||
nexusEcazBetrayal: 'Ecaz Nexus Betrayal',
|
||||
nexusGuildCunning: 'Guild Nexus Cunning',
|
||||
bgCharity: 'Bene Gesserit charity',
|
||||
choamCharity: 'CHOAM charity',
|
||||
choamInflation: 'CHOAM inflation',
|
||||
choamSale: 'a CHOAM sale',
|
||||
choamBattleIncome: 'CHOAM battle income',
|
||||
choamAudit: 'the CHOAM audit',
|
||||
choamBattleAid: 'CHOAM battle aid',
|
||||
worthlessKarama: 'Worthless-card Karama substitution',
|
||||
faceDancerReplacement: 'Face Dancer replacement',
|
||||
choamRevival: 'CHOAM revival',
|
||||
revivalLimit: 'the revival limit',
|
||||
revivalDiscount: 'the revival discount',
|
||||
earlyRevival: 'early revival',
|
||||
revivalIncome: 'revival income',
|
||||
foreignGhola: 'a foreign ghola',
|
||||
ixSubstitution: 'Ixian substitution',
|
||||
ixMovement: 'Ixian movement',
|
||||
fremenMovement: 'Fremen movement',
|
||||
mobileStronghold: 'Hidden Mobile Stronghold movement',
|
||||
richeseAuction: 'the Richese auction',
|
||||
richeseBlackMarket: 'the Richese Black Market',
|
||||
ixAuction: 'Ixian auction selection',
|
||||
ixTechnology: 'Ixian auction technology',
|
||||
ixAllyCard: 'the Ixian allied card',
|
||||
} satisfies Record<OrdinaryResponseKind, string>;
|
||||
|
||||
const purposeLabels = new Map<KullPurpose, string>([
|
||||
['shipment-price', 'Karama shipment pricing'],
|
||||
['auction-free-purchase', 'a free Treachery purchase'],
|
||||
]);
|
||||
for (const faction of FACTIONS) {
|
||||
purposeLabels.set(`special:${faction.id}`, `the ${faction.name} special Karama power`);
|
||||
purposeLabels.set(`cancel:eliteStrength:${faction.id}`, `cancellation of ${faction.name} special-force strength`);
|
||||
}
|
||||
for (const [kind, label] of Object.entries(cancellationLabels) as [OrdinaryResponseKind, string][])
|
||||
purposeLabels.set(`cancel:${kind}`, `cancellation of ${label}`);
|
||||
for (const [effect, label] of Object.entries(worthlessLabels) as [WorthlessPurposeEffect, string][])
|
||||
purposeLabels.set(`cancel:choamWorthless:${effect}`, `cancellation of ${label}`);
|
||||
|
||||
export function isKullPurpose(value: unknown): value is KullPurpose {
|
||||
return typeof value === 'string' && purposeLabels.has(value as KullPurpose);
|
||||
}
|
||||
export function kullCancellationPurpose(kind: ResponseWindow['kind'], detail?: string): KullPurpose {
|
||||
if (kind === 'choamWorthless') {
|
||||
if (typeof detail !== 'string' || !Object.hasOwn(worthlessLabels, detail))
|
||||
throw new Error('Kull cancellation needs the canonical CHOAM effect.');
|
||||
return `cancel:choamWorthless:${detail as WorthlessPurposeEffect}`;
|
||||
}
|
||||
if (kind === 'eliteStrength') {
|
||||
if (!FACTIONS.some(faction => faction.id === detail))
|
||||
throw new Error('Kull cancellation needs the special-force owner faction.');
|
||||
return `cancel:eliteStrength:${detail as FactionId}`;
|
||||
}
|
||||
const canonical = kind === 'nexusPrescience' ? 'prescience' : kind === 'nexusAdvisorFlip' ? 'advisorFlip' : kind;
|
||||
if (detail !== undefined || !Object.hasOwn(cancellationLabels, canonical))
|
||||
throw new Error('Kull cancellation needs one canonical response purpose without extra details.');
|
||||
return `cancel:${canonical}`;
|
||||
}
|
||||
export function kullSpecialPurpose(faction: FactionId): KullPurpose {
|
||||
if (!FACTIONS.some(candidate => candidate.id === faction))
|
||||
throw new Error('Kull special Karama needs the actual native faction.');
|
||||
return `special:${faction}`;
|
||||
}
|
||||
export function kullPurposeLabel(purpose: KullPurpose): string {
|
||||
if (!isKullPurpose(purpose)) throw new Error('Kull needs a canonical purpose.');
|
||||
return purposeLabels.get(purpose)!;
|
||||
}
|
||||
|
||||
/** Projection messages belong to the engine. Legacy all-purpose restrictions
|
||||
* take priority; a purpose-specific entry blocks only the requested purpose. */
|
||||
export function kullPurposeBlock(view: {
|
||||
karamaBlocked?: string | null;
|
||||
karamaPurposeBlocks?: readonly { purpose: KullPurpose; message: string }[];
|
||||
}, purpose: KullPurpose): string | null {
|
||||
if (view.karamaBlocked) return view.karamaBlocked;
|
||||
return isKullPurpose(purpose)
|
||||
? view.karamaPurposeBlocks?.find(entry => entry.purpose === purpose)?.message ?? null : null;
|
||||
}
|
||||
+8
-8
@@ -2653,7 +2653,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each opens a separate native cancellation response and stays spent if the power is denied. In the Semuta preview, its physical cost offers recovery before that response; the card remains Worthless for its new faction. A later printed cancellation cost is a separate fresh event whose recovery does not undo denial. Allowed converted prices retain the original owner/beneficiary and claimed-cost refund boundary. Normal printed Karama has no BG conversion response.',
|
||||
'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.',
|
||||
'Atreides full-plan inspection, Emperor revival, Fremen’s summoned worm, Harkonnen hand exchange the Guild shipment stop and Tleilaxu revival prevention are implemented as once-per-game uses. Other cancellations and special Karamas are still being implemented.',
|
||||
'In the opt-in Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase. See Kull Wahad for the explicit preview policy and deferred winning-overbid warning.',
|
||||
'In the opt-in Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase in Basic. New Advanced attempts instead restrict that specific effect; phase expiry is provisional. See Kull Wahad for the explicit preview policy and deferred winning-overbid warning.',
|
||||
],
|
||||
related: ['bidding', 'battle', 'advanced-combat', 'special-karama', 'choam-kull', 'nexus-choam-kull'],
|
||||
},
|
||||
@@ -3731,22 +3731,22 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'This opt-in development preview supports CHOAM with classic factions in Basic or Advanced, using the physical CHOAM and Ix Treachery decks without optional modules. Basic supports printed ordinary Karama; Advanced also supports Bene Gesserit substitutions and the legal special powers of this roster. Existing games are not retrofitted. Normal expansion starts remain closed, and the visible preview warning is not rules-completion approval.',
|
||||
'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.',
|
||||
'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.',
|
||||
'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. The activating player cannot activate printed or substituted Karama again during this turn’s current phase. An allied shipment recipient is not automatically the restricted player.',
|
||||
'The successful restriction ends when the phase or turn changes, not during a temporary response, market or worm substep. It does not prohibit holding, acquiring, transferring or otherwise legally discarding a Karama without activating it.',
|
||||
'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. Basic prevents that player’s Karama activations for this phase. A new Advanced attempt instead prevents only the specific effect: shipment pricing, free Treachery purchase, that faction’s special, or cancellation of the particular advantage. Other effects remain available; printed and substituted Karama share the same restriction. An allied shipment recipient is not automatically the restricted player.',
|
||||
'The successful restriction ends when the phase or turn changes, not during a temporary response, market or worm substep. Advanced effect scope follows the supplied rules; retaining phase expiry is a provisional interpretation. Immediate purchase and winning payment share one effect, as do force/leader or route variants of the same faction special. Old unstamped pending attempts and restrictions retain their original broad scope. Holding, acquiring, transferring and otherwise legal nonactivation discards remain available.',
|
||||
'A successful distinct counter spends or converts its own card under ordinary rules. Kull then stays held and its exact physical card cannot use its CHOAM effect again that phase. No Karama ban is established on the original player, and the reserved original attempt resumes once with its normal costs. Prevention is not a refund of a counter card.',
|
||||
'Advanced Bene Gesserit is intercepted before Worthless conversion or discard: successful Kull keeps that original unplayed Worthless card held. If CHOAM declines or Kull is countered, the original proceeds to its separate conversion response. Ordinary cancellation of that conversion still leaves its Worthless card discarded. A different BG Worthless counter has its own conversion response; it is never the reserved original.',
|
||||
'Winning overbids whose Karama payment would become unplayable remain deferred. To avoid accepting a bid that the preview cannot settle, a non-CHOAM unfunded Karama-dependent bid is refused privately before commitment, independently of CHOAM’s hidden Kull ownership. CHOAM self-activations are outside interception; normal non-preview bidding is unchanged. An already unfunded winning activation remains guarded. Holding Karama alone is not a reaction trigger. This unfinished-composition fence does not restart bidding, award the lot free, supply new funding or change numeric UI bid limits.',
|
||||
'A live Truthtrance battle or shipment promise also constrains original attempts, Kull costs and counters. A cost that cannot be proved compatible is unavailable before disposal; CHOAM may decline instead. Proven supported cancellations remain available. Pending Kull cannot release a suspended promise, and its counter choices exclude unproven costs so legal AI can progress. Unsupported continuations remain an incomplete preview composition, not a publisher ban or permission to release the promise.',
|
||||
'The different-card counter and before-conversion custody are explicitly selected development policy, not a located publisher clarification. This printed-only profile still excludes Nexus. The separate Nexus Kull profile adds one bounded Cunning interaction without changing older games; other expansion rosters, optional modules, unpayable-auction recovery and full CHOAM acceptance remain outside both previews. Nexus cards do not receive blanket immunity to Karama.',
|
||||
],
|
||||
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held and BG cannot activate any Worthless-as-Karama again this phase. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
|
||||
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
|
||||
related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Opt-in printed Kull interception binds typed ordinary or prepared special intents, distinct physical counters and a stamped activation restriction. Deferred overbid recovery and combined-module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Opt-in Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Deferred overbid recovery and combined-module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused engine, controls, all-profile legal bots and authenticated SQLite cover saved offer/counter/decline, exact custody, promises, orphan rejection and profile safeguards. Actual phone-width Basic/Advanced seats exercised Kull use, distinct counter and refreshed decline through the table. Complete combined games, proxy-free checkpoint HTTP and deployed acceptance remain separate.', evidence: ['tests/choam-kull-engine.test.ts', 'tests/choam-kull-recovery.test.ts', 'tests/choam-kull-controls.test.tsx', 'tests/bot-choam-kull.test.ts', 'tests/choam-kull.test.ts', 'tests/prototype-room.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: '18 October Advanced scope:12-file111/111, types/lint/build and two genuine-action/rendered-table programmes pass. Selected unopened deal/auction/Spice positions yield real Baliset purchase, paid shipments, repeated-effect rejection and a different-effect cancellation; Basic retains broad scope. Four policies legally pass. Controlled intent tests cover shared auction/special variants and unstamped frames. Earlier SQLite/phone evidence concerns the old scope, not current recovery/browser certification. Original Nexus/alias programmes, wider combinations, final assurance and deployed acceptance remain open.', evidence: ['tests/kull-purpose.test.ts', 'tests/kull-purpose-intents.test.ts', 'tests/kull-purpose-engine.test.ts', 'tests/fixture-kull-purpose.ts', 'tests/choam-kull-engine.test.ts', 'tests/choam-kull-recovery.test.ts', 'tests/choam-kull-controls.test.tsx', 'tests/bot-choam-kull.test.ts', 'tests/choam-kull.test.ts', 'tests/prototype-room.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -4447,12 +4447,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'In the explicit Nexus Kull preview, choose one actual Treachery fuel card at a Karama attempt; the CHOAM Nexus is spent when the declaration is accepted, even if Kull is prevented.',
|
||||
steps: [
|
||||
'This is the fresh local nexus-kull development profile, not a public expansion start or an upgrade to existing saves. It admits two to six ready seats with native CHOAM and otherwise classic factions in Basic or Advanced, physical CHOAM and Ix Treachery decks, and Nexus alone. Other optional modules, expansion factions, Semuta and Richese Betrayal stay outside its boundary. The older kull profile remains printed-only and has no Nexus cards.',
|
||||
'The CHOAM Nexus face says: Discard any Treachery Card to obtain a Worthless Card special effect of your choice. The CHOAM rulebook gives Kull its attempted-Karama timing and phase restriction. Cunning changes the fuel identity, not the effect, target or timing. Neither text supplies a nested counter priority: distinct counters and BG-before-conversion custody are the recorded user-selected policy, not a publisher clarification.',
|
||||
'The CHOAM Nexus face says: Discard any Treachery Card to obtain a Worthless Card special effect of your choice. Basic uses the CHOAM phase-wide scope; new Advanced attempts use the supplied specific-effect scope with provisional phase expiry. Cunning changes the fuel identity, not the effect, target or timing. Neither text supplies a nested counter priority: distinct counters and BG-before-conversion custody are the recorded user-selected policy, not a publisher clarification.',
|
||||
'Wait for another player’s validated Karama attempt. There is no proactive Kull choice in the generic Nexus panel. The public event, attempted player and purpose are the same whether CHOAM holds CHOAM Nexus, another Nexus, no eligible fuel or no Nexus at all. Only CHOAM receives its private source-aware plays and blocking reasons; opponents do not see the cost list or reserved original card.',
|
||||
'Choose a cost through the shared selector and inspect that actual card before Use. Printed source requires a held printed Kull Wahad. Nexus source requires unallied native CHOAM holding the actual CHOAM Nexus in this profile and one uniquely held canonical Treachery Card: a weapon, defense, special, Karama or Worthless card can be fuel. Reserved or promise-incompatible fuel is unavailable. The card does not become a manufactured Kull, and Karama used as fuel does not activate its own power.',
|
||||
'Use must identify the offered event, selected source (printed or nexus) and actual fuel card. Omitting the source, guessing another event or supplying someone else’s card is not a valid Use. Decline needs only the event and spends nothing; the exact original attempt resumes once. You do not choose a new target, price, effect or Nexus identity.',
|
||||
'An accepted Nexus declaration discards the physical CHOAM Nexus once immediately. The selected fuel stays held while the ordinary CHOAM-effect response is pending. Pending Kull is not yet a phase ban. The interrupted original stays reserved; it cannot counter its own interruption. A distinct eligible printed Karama or Advanced BG substitution uses the existing response controls before any restriction begins.',
|
||||
'If Kull succeeds, discard exactly the selected fuel once, retain the original unplayed Karama or BG Worthless card and leave the original special once-use and additional costs unspent. The activating player, not an allied shipment beneficiary, cannot activate printed or substituted Karama for the stamped current turn and phase. This does not ban holding, acquiring, trading or otherwise legally discarding Karama.',
|
||||
'If Kull succeeds, discard exactly the selected fuel once, retain the original unplayed Karama or BG Worthless card and leave the original special once-use and additional costs unspent. The activating player, not an allied shipment beneficiary, receives the stamped restriction: Basic/legacy all-purpose, or the new Advanced attempted effect only. Another legal Advanced effect remains available, using either printed or substituted Karama. This does not ban holding, acquiring, trading or otherwise legally discarding Karama.',
|
||||
'If a distinct counter prevents Kull, the selected fuel remains held under the existing exact-cost phase prevention policy, but the accepted Nexus spend is not refunded. No Kull activation ban is established. Resume the original attempt once with its normal costs. A BG original resumes its separate ordinary conversion response; ordinary prevention of that conversion still leaves its Worthless card discarded. A BG counter likewise owns its separate conversion, never the reserved original.',
|
||||
'The selected deferred-overbid policy still privately refuses non-CHOAM unfunded Karama-dependent bids before commitment, independently of hidden Kull or Nexus ownership. No auction restart, free award, invented funding or numeric UI cap is added. Own Truthtrance promises and reservations constrain the original, fuel and counters; unsupported continuations are guarded before an offer rather than silently releasing a promise.',
|
||||
'Refresh restores the same event, selected source, physical costs, spent Nexus and original continuation, including a suspended BG response. Corrupt ownership rejects without changing the game. Verified older printed-only counter saves receive a bounded immutable binding migration, not Nexus choices or a source-omitting action shim. Frozen checks and actual Basic/Advanced CLI/runtime/phone success and prevention are verified locally; complete CHOAM/Nexus, wider combinations and deployed acceptance remain open.',
|
||||
|
||||
@@ -35,7 +35,8 @@ void test('every profile uses the projected physical Kull and cannot act from a
|
||||
assert.equal(settled.karamaShipping, null);
|
||||
assert.ok(settled.players[1].hand.some(card => card.id === attemptedCard));
|
||||
assert.ok(settled.discard.some(card => card.id === 'ix-kull-wahad'));
|
||||
assert.ok(viewGame(settled, 'e').karamaBlocked);
|
||||
assert.equal(viewGame(settled, 'e').karamaBlocked, null);
|
||||
assert.deepEqual(viewGame(settled, 'e').karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price']);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ void test('all four profiles settle saved Basic and Advanced Nexus Kull with onl
|
||||
assert.equal(settled.karamaShipping, null);
|
||||
assert.equal(settled.discard.filter(card => card.id === fixture.fuel).length, 1);
|
||||
assert.ok(settled.players.find(player => player.id === fixture.actor)!.hand.some(card => card.id === fixture.original));
|
||||
assert.ok(viewGame(settled, fixture.actor).karamaBlocked);
|
||||
assert.equal(!!viewGame(settled, fixture.actor).karamaBlocked, !advanced);
|
||||
assert.deepEqual(viewGame(settled, fixture.actor).karamaPurposeBlocks.map(rule => rule.purpose), advanced ? ['shipment-price'] : []);
|
||||
assert.equal(settled.nexusCards!.cards!.hands[fixture.choam], null);
|
||||
assert.equal(settled.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1);
|
||||
assert.equal(settled.nexusChoamHistory!.at(-1)!.stage, 'complete');
|
||||
@@ -91,7 +92,8 @@ void test('all profiles leave the interrupted BG Worthless card unconverted when
|
||||
const original = settled.players.find(player => player.id === fixture.actor)!.hand.find(card => card.id === fixture.original);
|
||||
assert.equal(original?.kind, 'worthless');
|
||||
assert.ok(!settled.discard.some(card => card.id === fixture.original));
|
||||
assert.ok(viewGame(settled, fixture.actor).karamaBlocked);
|
||||
assert.equal(viewGame(settled, fixture.actor).karamaBlocked, null);
|
||||
assert.deepEqual(viewGame(settled, fixture.actor).karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price']);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ void test('the real saved table mounts the offer instead of shipment and volunta
|
||||
assert.match(disabled, /disabled=""[^>]*>Use Kull Wahad/);
|
||||
});
|
||||
|
||||
void test('a successful phase ban removes the retained Karama shipment-rate control', () => {
|
||||
void test('a successful effect restriction removes the retained Karama shipment-rate control', () => {
|
||||
const initial = choamKullGame();
|
||||
let g = applyAction(initial, 'e', kullShipmentAttempt(initial));
|
||||
g = applyAction(g, 'c', {
|
||||
@@ -102,9 +102,10 @@ void test('a successful phase ban removes the retained Karama shipment-rate cont
|
||||
const render = (game: GameView) => renderToStaticMarkup(createElement(GameTable, {
|
||||
game, send: async () => { assert.fail('Rendering must not submit gameplay.'); }, busy: false, onExit() {},
|
||||
}));
|
||||
assert.ok(blocked.karamaBlocked);
|
||||
assert.equal(blocked.karamaBlocked, null);
|
||||
assert.deepEqual(blocked.karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price']);
|
||||
assert.doesNotMatch(render(blocked), />Guild rates for/);
|
||||
const available = { ...blocked, karamaBlocked: null };
|
||||
const available = { ...blocked, karamaPurposeBlocks: [] };
|
||||
assert.match(render(available), />Guild rates for/);
|
||||
});
|
||||
|
||||
|
||||
@@ -378,7 +378,8 @@ void test('a canceled BG counter does not release the original card or premature
|
||||
assert.equal(result.karamaShipping, null);
|
||||
assert.equal(result.discard.filter(card => card.id === bg).length, 1);
|
||||
assert.equal(result.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
|
||||
assert.equal(viewGame(result, 'e').karamaBlocked !== null, true);
|
||||
assert.equal(viewGame(result, 'e').karamaBlocked, null);
|
||||
assert.deepEqual(viewGame(result, 'e').karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price']);
|
||||
});
|
||||
|
||||
void test('nonopted games keep native Karama execution and no Kull opportunity', () => {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeKullGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type KullPurposeStep = { actor: string; action: Action };
|
||||
export const kullPurposePlayer = (game: Game, actor = 'emperor') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const kullPurposeClean = (game: Game) => !game.pendingKull && !game.response &&
|
||||
!game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function actKullPurpose(game: Game, actor: string, action: Action, actions: KullPurposeStep[] = []): Game {
|
||||
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return next;
|
||||
}
|
||||
/** Neutral native choices only. No card is granted and no optional power or
|
||||
* sealed plan is selected by this finite continuation driver. */
|
||||
export function nextKullPurposeStep(game: Game): KullPurposeStep | null {
|
||||
if (game.pendingKull?.stage === 'offer') return { actor: game.pendingKull.player,
|
||||
action: { type: 'kullDecision', event: game.pendingKull.event, decline: true } };
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'choamMovement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (kullPurposeClean(game) && game.auction?.active === 'choam' && game.auction.bid === 0 &&
|
||||
game.auction.cards[game.auction.index].name === 'Baliset')
|
||||
return { actor: 'choam', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceKullPurpose(game: Game, until: (state: Game) => boolean,
|
||||
actions: KullPurposeStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Kull purpose programme ended before its next control');
|
||||
const step = nextKullPurposeStep(game); assert.ok(step, 'Complete the real private Kull purpose choice before advancing');
|
||||
game = actKullPurpose(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Kull purpose programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Explicit original Kull preview entry from a ready native lobby. The exact
|
||||
* initial deal is controlled while every card remains in the unopened deck:
|
||||
* CHOAM gets physical Kull and the two classic seats each get one physical
|
||||
* Karama. This is a selected deck order, not a naturally discovered random seed.
|
||||
* Baliset is subsequently bought in an actual ordinary auction. */
|
||||
export function createKullPurposeFixture(options: { advanced?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? true;
|
||||
let game = createGame('KULLPURPOSE', newPlayer('choam', 'CHOAM', 'choam'), advanced, ['choam', 'ix']);
|
||||
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: KullPurposeStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actKullPurpose(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceKullPurpose(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeKullGameForAudit(game));
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0), 'Select only the unopened original starting deal');
|
||||
const front: Game['deck'] = [];
|
||||
for (const kind of ['kull', 'karama', 'karama']) {
|
||||
const at = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
|
||||
assert.ok(at >= 0); front.push(game.deck.splice(at, 1)[0]);
|
||||
}
|
||||
const unopenedDeal = front.map((card, index) => ({ actor: game.players[index].id, card: card.id }));
|
||||
game.deck.unshift(...front);
|
||||
const beforeInitialDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), karama = unopenedDeal[1].card, counter = unopenedDeal[2].card;
|
||||
assert.ok(kullPurposePlayer(game, 'choam').hand.some(card => card.id === 'ix-kull-wahad'));
|
||||
assert.ok(kullPurposePlayer(game).hand.some(card => card.id === karama));
|
||||
assert.ok(kullPurposePlayer(game, 'guild').hand.some(card => card.id === counter));
|
||||
const land: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < (advanced ? 2 : 1); count++) {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
||||
land.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...land);
|
||||
reach(state => state.phase === 2 && kullPurposeClean(state));
|
||||
const at = game.deck.findIndex(card => card.name === 'Baliset' && card.kind === 'worthless'); assert.ok(at >= 0);
|
||||
const baliset = game.deck[at].id;
|
||||
game.deck.unshift(game.deck.splice(at, 1)[0]);
|
||||
const beforePurchase = structuredClone(game);
|
||||
reach(state => kullPurposePlayer(state, 'choam').hand.some(card => card.id === baliset));
|
||||
const acquired = structuredClone(game);
|
||||
reach(state => state.phase === 5 && state.active === 'choam' && kullPurposeClean(state));
|
||||
const beforeChoamShipment = structuredClone(game), choamShipment: KullPurposeStep = {
|
||||
actor: 'choam', action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 },
|
||||
};
|
||||
act(choamShipment.actor, choamShipment.action); reach(kullPurposeClean);
|
||||
const afterChoamShipment = structuredClone(game);
|
||||
act('choam', { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === 'emperor' && kullPurposeClean(state));
|
||||
return { game, lobby, setup, beforeInitialDeal, unopenedDeal, afterSetup, beforePurchase, acquired,
|
||||
beforeChoamShipment, choamShipment, afterChoamShipment, karama, counter, baliset, kull: 'ix-kull-wahad',
|
||||
actor: 'emperor', reactor: 'choam', source: 'imperial_basin:10', destination: 'arrakeen:10',
|
||||
entry: 'initializeKullGameForAudit' as const, sourceActionCount: actions.length, actions,
|
||||
staging: ['Exact original unopened starting deal and unopened Baliset/Spice positions are selected, with scalar lottery order. Every subsequent card, wallet and force comes from native setup, actual paid auction and actual paid shipment; no playing-state inventory or clock is installed.'] };
|
||||
}
|
||||
export type KullPurposeFixture = ReturnType<typeof createKullPurposeFixture>;
|
||||
export const kullPurposeShipmentAction = (fixture: KullPurposeFixture): Action => ({
|
||||
type: 'card', mode: 'shipment', card: fixture.karama, target: fixture.actor,
|
||||
});
|
||||
|
||||
/** Success prevents the original price attempt before cost. A normal funded
|
||||
* shipment and real ground move then produce CHOAM's native Baliset response. */
|
||||
export function reachKullPurposeBaliset(fixture: KullPurposeFixture) {
|
||||
let game = structuredClone(fixture.game);
|
||||
const actions: KullPurposeStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actKullPurpose(game, actor, action, actions); };
|
||||
act(fixture.actor, kullPurposeShipmentAction(fixture));
|
||||
const offered = structuredClone(game), reaction = viewGame(game, fixture.reactor).kullReaction;
|
||||
assert.ok(reaction && reaction.plays.some(play => play.source === 'printed' && play.card.id === fixture.kull && !play.blocked));
|
||||
act(fixture.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: fixture.kull });
|
||||
const declared = structuredClone(game);
|
||||
game = advanceKullPurpose(game, kullPurposeClean, actions);
|
||||
const restricted = structuredClone(game), shipmentStep: KullPurposeStep = { actor: fixture.actor, action: {
|
||||
type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1, elite: game.advanced ? 1 : 0, allyPayment: 0,
|
||||
} };
|
||||
act(shipmentStep.actor, shipmentStep.action);
|
||||
game = advanceKullPurpose(game, kullPurposeClean, actions);
|
||||
const shipped = structuredClone(game), moveStep: KullPurposeStep = { actor: fixture.actor, action: {
|
||||
type: 'move', from: fixture.source, territory: 'arrakeen', sector: 10, amount: 1, elite: game.advanced ? 1 : 0,
|
||||
} };
|
||||
act(moveStep.actor, moveStep.action);
|
||||
const beforeBaliset = structuredClone(game);
|
||||
assert.equal(game.decision?.kind, 'choamMovement');
|
||||
act(fixture.reactor, { type: 'card', mode: 'choam', card: fixture.baliset, target: fixture.actor, territory: 'arrakeen' });
|
||||
const baliset = structuredClone(game);
|
||||
assert.equal(game.response?.kind, 'choamWorthless');
|
||||
assert.equal(game.response.intent, 'Baliset');
|
||||
return { game, offered, declared, restricted, shipped, shipmentStep, beforeBaliset, moveStep, baliset, actions };
|
||||
}
|
||||
|
||||
/** The retained original Karama may cancel a different purpose in Advanced.
|
||||
* Its second neutral Kull offer is actually declined after the cost was spent;
|
||||
* the native Baliset cancellation resumes exactly the declared typed move. */
|
||||
export function completeKullPurpose(fixture: KullPurposeFixture) {
|
||||
assert.equal(fixture.game.advanced, true, 'Basic retains its original whole-phase restriction');
|
||||
const programme = reachKullPurposeBaliset(fixture), actions = [...programme.actions];
|
||||
let game = actKullPurpose(programme.game, fixture.actor,
|
||||
{ type: 'card', mode: 'cancel', card: fixture.karama }, actions);
|
||||
const differentOffer = structuredClone(game), reaction = viewGame(game, fixture.reactor).kullReaction;
|
||||
assert.ok(reaction && reaction.plays.every(play => play.card.id !== fixture.kull));
|
||||
game = actKullPurpose(game, fixture.reactor, { type: 'kullDecision', event: reaction.event, decline: true }, actions);
|
||||
game = advanceKullPurpose(game, kullPurposeClean, actions);
|
||||
const completed = structuredClone(game);
|
||||
game = actKullPurpose(game, fixture.actor, { type: 'endMovement' }, actions);
|
||||
return { ...programme, game, completed, differentOffer, actions };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
actKullPurpose, advanceKullPurpose, completeKullPurpose, createKullPurposeFixture,
|
||||
kullPurposeClean, kullPurposePlayer as player, kullPurposeShipmentAction,
|
||||
reachKullPurposeBaliset, type KullPurposeFixture,
|
||||
} from './fixture-kull-purpose';
|
||||
|
||||
function source(f: KullPurposeFixture) {
|
||||
assert.equal(f.entry, 'initializeKullGameForAudit');
|
||||
assert.equal(f.setup.kullPreview, true);
|
||||
assert.ok(f.beforeInitialDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.beforeInitialDeal.deck.slice(0, 3).map(card => card.id), f.unopenedDeal.map(deal => deal.card));
|
||||
for (const deal of f.unopenedDeal)
|
||||
assert.ok(player(f.afterSetup, deal.actor).hand.some(card => card.id === deal.card));
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.ok(f.actions.some(step => step.actor === f.reactor && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.ok(player(f.acquired, f.reactor).hand.some(card => card.id === f.baliset));
|
||||
assert.equal(player(f.beforeChoamShipment, f.reactor).spice - player(f.afterChoamShipment, f.reactor).spice, 1);
|
||||
assert.equal(player(f.game, f.reactor).forces[f.destination], 1);
|
||||
assert.equal(f.game.phase, 5);
|
||||
assert.equal(f.game.active, f.actor);
|
||||
assert.equal(player(f.game).shipped, false);
|
||||
}
|
||||
function restriction(f: KullPurposeFixture, programme: ReturnType<typeof reachKullPurposeBaliset>) {
|
||||
const { offered, restricted } = programme;
|
||||
const reaction = viewGame(offered, f.reactor).kullReaction!;
|
||||
assert.equal(reaction.scope, f.game.advanced ? 'effect' : 'phase');
|
||||
assert.match(reaction.effect, /shipment|price|discount/i);
|
||||
assert.ok(player(offered).hand.some(card => card.id === f.karama));
|
||||
assert.equal(offered.karamaShipping, null);
|
||||
assert.equal(restricted.pendingKull ?? null, null);
|
||||
assert.equal(restricted.karamaShipping, null);
|
||||
assert.equal(player(restricted).spice, player(f.game).spice);
|
||||
assert.equal(player(restricted).shipped, false);
|
||||
assert.equal(player(restricted).specialKaramaUsed, undefined);
|
||||
assert.ok(player(restricted).hand.some(card => card.id === f.karama));
|
||||
assert.equal(restricted.discard.some(card => card.id === f.karama), false);
|
||||
assert.equal(restricted.discard.filter(card => card.id === f.kull).length, 1);
|
||||
const receipt = restricted.kullRestrictions!.find(receipt => receipt.player === f.actor)!;
|
||||
assert.deepEqual(receipt, { player: f.actor, turn: restricted.turn, phase: 5,
|
||||
...(f.game.advanced ? { purpose: 'shipment-price' } : {}) });
|
||||
const before = JSON.stringify(restricted);
|
||||
assert.throws(() => actKullPurpose(restricted, f.actor, kullPurposeShipmentAction(f)), /Kull Wahad/i);
|
||||
assert.equal(JSON.stringify(restricted), before, 'Repeating the blocked purpose stops before a fresh offer, card cost or funding change');
|
||||
}
|
||||
|
||||
void test('genuine Advanced Kull blocks shipment-price while retained Karama cancels native Baliset in the same phase', () => {
|
||||
const f = createKullPurposeFixture();
|
||||
source(f);
|
||||
const result = completeKullPurpose(f);
|
||||
restriction(f, result);
|
||||
const own = viewGame(result.restricted, f.actor);
|
||||
assert.equal(own.karamaBlocked, null, 'A single-purpose restriction is not an all-purpose activation ban');
|
||||
const blocks = own.karamaPurposeBlocks;
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0].purpose, 'shipment-price');
|
||||
assert.ok(blocks[0].label.length > 0 && blocks[0].message.length > 0);
|
||||
assert.equal(player(result.restricted).spice - player(result.shipped).spice, 2,
|
||||
'The denied price benefit does not alter the real one-counter desert shipment cost');
|
||||
assert.equal(player(result.shipped).forces[f.source], 1);
|
||||
assert.equal(player(result.shipped).elites!.forces[f.source], 1);
|
||||
assert.equal(player(result.beforeBaliset).moved, 0);
|
||||
assert.ok(viewGame(result.baliset, f.actor).responseControls!.cancelCards.includes(f.karama));
|
||||
assert.equal(result.differentOffer.turn, result.restricted.turn);
|
||||
assert.equal(result.differentOffer.phase, result.restricted.phase);
|
||||
assert.notEqual(result.differentOffer.pendingKull!.event, result.offered.pendingKull!.event);
|
||||
assert.equal(viewGame(result.differentOffer, f.reactor).kullReaction!.scope, 'effect');
|
||||
assert.ok(viewGame(result.differentOffer, f.reactor).kullReaction!.plays.every(play => play.card.id !== f.kull));
|
||||
const done: Game = result.completed;
|
||||
assert.equal(done.phase, 5);
|
||||
assert.equal(done.pendingKull ?? null, null);
|
||||
assert.equal(done.pendingChoamMove ?? null, null);
|
||||
assert.equal(done.pendingChoamWorthless ?? null, null);
|
||||
assert.deepEqual(done.kullRestrictions, result.restricted.kullRestrictions,
|
||||
'Declining the different-purpose opportunity adds no new restriction');
|
||||
assert.equal(player(done).moved, 1);
|
||||
assert.equal(player(done).forces[f.source] ?? 0, 0);
|
||||
assert.equal(player(done).forces[f.destination], 1);
|
||||
assert.equal(player(done).elites!.forces[f.destination], 1);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(done.discard.filter(card => card.id === f.kull).length, 1);
|
||||
assert.ok(player(done, f.reactor).hand.some(card => card.id === f.baliset),
|
||||
'Native prevention retains the canceled Baliset cost under the existing CHOAM policy');
|
||||
assert.deepEqual(result.game.movementRemaining, []);
|
||||
assert.equal(result.game.decision?.kind, 'choamMarket', 'Ending movement reaches the real CHOAM phase-end market');
|
||||
});
|
||||
|
||||
void test('genuine Basic Kull retains the phase-wide policy at the same paid shipment and native Baliset opportunity', () => {
|
||||
const f = createKullPurposeFixture({ advanced: false });
|
||||
source(f);
|
||||
const programme = reachKullPurposeBaliset(f);
|
||||
restriction(f, programme);
|
||||
assert.ok(viewGame(programme.restricted, f.actor).karamaBlocked);
|
||||
assert.equal(viewGame(programme.restricted, f.actor).karamaPurposeBlocks.length, 0);
|
||||
assert.equal(viewGame(programme.baliset, f.actor).responseControls!.cancelCards.includes(f.karama), false);
|
||||
assert.throws(() => actKullPurpose(programme.baliset, f.actor,
|
||||
{ type: 'card', mode: 'cancel', card: f.karama }), /Kull Wahad/i);
|
||||
const allowed = advanceKullPurpose(programme.game, kullPurposeClean);
|
||||
assert.equal(player(allowed).moved, 0);
|
||||
assert.equal(player(allowed).forces[f.source], 1);
|
||||
assert.ok(player(allowed).hand.some(card => card.id === f.karama));
|
||||
assert.equal(allowed.discard.filter(card => card.id === f.baliset).length, 1);
|
||||
});
|
||||
|
||||
void test('all four minimal policies make a legal different-purpose response after original Advanced Kull success', () => {
|
||||
const f = createKullPurposeFixture();
|
||||
const programme = reachKullPurposeBaliset(f);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(programme.baliset, f.actor);
|
||||
view.players.find(player => player.id === f.actor)!.bot = difficulty;
|
||||
assert.ok(view.responseControls!.cancelCards.includes(f.karama));
|
||||
const action = botActions(view).find(action => action.type === 'passResponse' ||
|
||||
(action.type === 'card' && action.mode === 'cancel' && action.card === f.karama));
|
||||
assert.ok(action, `${difficulty} retains a legal native response without a blanket Karama filter`);
|
||||
let next = actKullPurpose(structuredClone(programme.game), f.actor, action);
|
||||
if (action.type === 'card') {
|
||||
const reaction = viewGame(next, f.reactor).kullReaction!;
|
||||
assert.ok(reaction);
|
||||
next = actKullPurpose(next, f.reactor, { type: 'kullDecision', event: reaction.event, decline: true });
|
||||
next = advanceKullPurpose(next, kullPurposeClean);
|
||||
assert.equal(player(next).moved, 1);
|
||||
assert.equal(next.discard.filter(card => card.id === f.karama).length, 1);
|
||||
} else {
|
||||
next = advanceKullPurpose(next, kullPurposeClean);
|
||||
assert.equal(player(next).moved, 0);
|
||||
assert.ok(player(next).hand.some(card => card.id === f.karama));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
function allowKull(offer: Game): Game {
|
||||
let g = applyAction(offer, 'c', {type:'kullDecision', event:offer.pendingKull!.event,
|
||||
source:'printed', card:'ix-kull-wahad'});
|
||||
for (let step = 0; g.pendingKull && step < 16; step++) {
|
||||
const responder = g.players.find(p => {
|
||||
const controls = viewGame(g, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length;
|
||||
});
|
||||
g = responder ? applyAction(g, responder.id, {type:'passResponse'}) : normalizeAutomaticGame(g);
|
||||
}
|
||||
assert.equal(g.pendingKull, null);
|
||||
return g;
|
||||
}
|
||||
function normalLot(g: Game) {
|
||||
g.phase = 3; g.movementRemaining = [];
|
||||
g.auction = {cards:[g.deck.shift()!], index:0, bid:0, bidder:null,
|
||||
active:'e', passed:[], opener:0};
|
||||
}
|
||||
|
||||
void test('Advanced Emperor force and leader specials share one effect while ordinary cancellation remains legal', () => {
|
||||
const initial = choamKullGame(); initial.phase = 4;
|
||||
const emperor = player(initial, 'e'); emperor.tanks = 2; emperor.reserves -= 2;
|
||||
const leader = emperor.leaders[0]; leader.dead = true;
|
||||
const la = takeKullCard(initial, 'La La La'); player(initial, 'c').hand.push(la);
|
||||
const card = emperor.hand[0].id;
|
||||
const offer = applyAction(initial, 'e', {type:'card', mode:'special', card, amount:1});
|
||||
assert.equal(offer.pendingKull?.purpose, 'special:emperor');
|
||||
const stopped = allowKull(offer);
|
||||
assert.equal(player(stopped, 'e').specialKaramaUsed, undefined);
|
||||
assert.deepEqual(stopped.kullRestrictions, [{player:'e',turn:2,phase:4,purpose:'special:emperor'}]);
|
||||
for (const change of [{amount:2}, {leader:leader.id}]) {
|
||||
const before = JSON.stringify(stopped);
|
||||
assert.throws(() => applyAction(stopped, 'e', {type:'card',mode:'special',card,...change}), /Kull Wahad/);
|
||||
assert.equal(JSON.stringify(stopped), before);
|
||||
}
|
||||
const parent = applyAction(stopped, 'c', {type:'card',mode:'choam',card:la.id,target:'e'});
|
||||
assert.ok(viewGame(parent, 'e').responseControls?.cancelCards.includes(card));
|
||||
const cancelOffer = applyAction(parent, 'e', {type:'card',mode:'cancel',card});
|
||||
assert.equal(cancelOffer.pendingKull?.purpose, 'cancel:choamWorthless:laLaLa');
|
||||
const canceled = applyAction(cancelOffer, 'c', {type:'kullDecision',event:cancelOffer.pendingKull!.event,decline:true});
|
||||
assert.ok(player(canceled, 'c').hand.some(c => c.id === la.id));
|
||||
assert.ok(canceled.choamWorthlessBlocked?.cards.includes(la.id));
|
||||
assert.equal(canceled.discard.filter(c => c.id === card).length, 1);
|
||||
});
|
||||
|
||||
void test('Advanced free purchase and winning Karama payment share one effect and do not block a faction special', () => {
|
||||
const initial = choamKullGame({opponents:['harkonnen','beneGesserit','emperor']});
|
||||
normalLot(initial);
|
||||
const card = player(initial, 'e').hand[0].id;
|
||||
const offer = applyAction(initial, 'e', {type:'card',mode:'purchase',card});
|
||||
assert.equal(offer.pendingKull?.purpose, 'auction-free-purchase');
|
||||
const stopped = allowKull(offer);
|
||||
assert.throws(() => applyAction(stopped, 'e', {type:'card',mode:'purchase',card}), /Kull Wahad/);
|
||||
// Controlled winning-payment state on the same original unresolved physical lot.
|
||||
const payment = structuredClone(stopped);
|
||||
payment.auction!.bid = 1; payment.auction!.bidder = 'e';
|
||||
payment.decision = {kind:'auctionPayment',player:'e'};
|
||||
assert.throws(() => applyAction(payment, 'e', {type:'decision',karama:true,card}), /Kull Wahad/);
|
||||
assert.ok(player(payment, 'e').hand.some(c => c.id === card));
|
||||
const special = applyAction(stopped, 'e', {type:'card',mode:'special',card,target:'b',amount:1});
|
||||
assert.equal(special.pendingKull?.purpose, 'special:harkonnen');
|
||||
const continued = applyAction(special, 'c', {type:'kullDecision',event:special.pendingKull!.event,decline:true});
|
||||
assert.equal(player(continued, 'e').specialKaramaUsed, true);
|
||||
assert.equal(continued.discard.filter(c => c.id === card).length, 1);
|
||||
// Spending the only original card leaves a forced one-card return; it completes automatically.
|
||||
assert.equal(continued.pendingExchange ?? null, null);
|
||||
assert.equal(player(continued, 'b').hand.length, 1);
|
||||
});
|
||||
|
||||
void test('Basic attempts and unstamped Advanced attempts retain the original all-purpose phase restriction', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const initial = choamKullGame({advanced});
|
||||
let offer = applyAction(initial, 'e', kullShipmentAttempt(initial));
|
||||
if (advanced) {
|
||||
// Exact legacy protocol fixture: old signed JSON omitted this new field.
|
||||
offer = structuredClone(offer);
|
||||
delete offer.pendingKull!.purpose;
|
||||
const signature = JSON.parse(offer.pendingKull!.signature);
|
||||
delete signature.purpose;
|
||||
offer.pendingKull!.signature = JSON.stringify(signature);
|
||||
}
|
||||
assert.equal(viewGame(offer, 'c').kullReaction?.scope, 'phase');
|
||||
const stopped = allowKull(offer);
|
||||
assert.deepEqual(stopped.kullRestrictions, [{player:'e',turn:2,phase:5}]);
|
||||
assert.ok(viewGame(stopped, 'e').karamaBlocked);
|
||||
assert.deepEqual(viewGame(stopped, 'e').karamaPurposeBlocks, []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import type { ResponseWindow } from '../game/engine';
|
||||
import {
|
||||
isKullPurpose, kullCancellationPurpose, kullSpecialPurpose, kullPurposeLabel,
|
||||
kullPurposeBlock, type KullPurpose,
|
||||
} from '../game/kull-purpose';
|
||||
import {
|
||||
activeKullRestrictions, kullBlocksKarama, validateKullPhaseRestrictions,
|
||||
type KullContext, type KullPhaseRestriction,
|
||||
} from '../game/choam-kull';
|
||||
|
||||
const context: KullContext = { turn: 3, phase: 5, players: [
|
||||
{ id: 'choam', faction: 'choam' }, { id: 'actor', faction: 'emperor' }, { id: 'other', faction: 'guild' },
|
||||
] };
|
||||
const stamp = { player: 'actor', turn: 3, phase: 5 };
|
||||
|
||||
void test('Advanced Kull restricts the exact public purpose while legacy/Basic restrictions remain all-purpose', () => {
|
||||
const history: KullPhaseRestriction[] = [{ ...stamp, purpose: 'shipment-price' }];
|
||||
validateKullPhaseRestrictions(context, history);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', 'shipment-price'), true);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', 'auction-free-purchase'), false);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', kullSpecialPurpose('emperor')), false);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor'), false);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'other', 'shipment-price'), false);
|
||||
const legacy = [stamp];
|
||||
assert.equal(kullBlocksKarama(legacy, 3, 5, 'actor'), true);
|
||||
for (const purpose of ['shipment-price', 'auction-free-purchase', 'special:emperor', 'cancel:voice'] as const)
|
||||
assert.equal(kullBlocksKarama(legacy, 3, 5, 'actor', purpose), true);
|
||||
});
|
||||
|
||||
void test('active purpose history is copied faithfully and expires by both phase and turn', () => {
|
||||
const current = Object.freeze({ ...stamp, purpose: 'cancel:choamWorthless:baliset' as const });
|
||||
const history = Object.freeze([current, Object.freeze({ ...stamp, turn: 2, purpose: 'special:emperor' as const })]);
|
||||
validateKullPhaseRestrictions(context, history);
|
||||
const active = activeKullRestrictions(history, 3, 5);
|
||||
assert.deepEqual(active, [current]); assert.notEqual(active[0], current);
|
||||
for (const [turn, phase] of [[3, 6], [4, 5], [4, 0]]) {
|
||||
assert.deepEqual(activeKullRestrictions(history, turn, phase), []);
|
||||
assert.equal(kullBlocksKarama(history, turn, phase, 'actor', current.purpose), false);
|
||||
}
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', 'cancel:choamWorthless:kulon'), false);
|
||||
});
|
||||
|
||||
void test('distinct same-phase purposes coexist, while exact purpose and legacy duplicates reject', () => {
|
||||
const history: KullPhaseRestriction[] = [stamp, { ...stamp, purpose: 'shipment-price' },
|
||||
{ ...stamp, purpose: 'auction-free-purchase' }, { ...stamp, purpose: 'cancel:voice' }];
|
||||
validateKullPhaseRestrictions(context, history);
|
||||
assert.equal(activeKullRestrictions(history, 3, 5).length, 4);
|
||||
for (const repeated of history)
|
||||
assert.throws(() => validateKullPhaseRestrictions(context, [...history, { ...repeated }]), /duplicate/);
|
||||
});
|
||||
|
||||
void test('restriction history rejects noncanonical purpose strings, undefined extra fields, unrelated fields and future stamps', () => {
|
||||
const invalid: unknown[] = [
|
||||
undefined, null, '', 'all', 'special:Emperor', 'special:emperor:extra', 'cancel:choamWorthless',
|
||||
'cancel:choamWorthless:lalala', 'cancel:choamWorthless:laLaLa:extra', 'cancel:eliteStrength',
|
||||
'cancel:eliteStrength:unknown', 'cancel:voice:amount', 'cancel:constructor', 5, {},
|
||||
'cancel:nexusPrescience', 'cancel:nexusAdvisorFlip',
|
||||
];
|
||||
for (const purpose of invalid) {
|
||||
const restriction = { ...stamp, purpose }, before = structuredClone(restriction);
|
||||
assert.throws(() => validateKullPhaseRestrictions(context, [restriction]));
|
||||
assert.deepEqual(restriction, before);
|
||||
assert.equal(isKullPurpose(purpose), false);
|
||||
}
|
||||
for (const restriction of [
|
||||
{ ...stamp, purpose: 'shipment-price', extra: true },
|
||||
{ ...stamp, purpose: 'shipment-price', turn: 4 },
|
||||
{ ...stamp, purpose: 'shipment-price', phase: 6 },
|
||||
]) assert.throws(() => validateKullPhaseRestrictions(context, [restriction]));
|
||||
});
|
||||
|
||||
void test('each CHOAM ordinary power has its own canonical cancellation purpose without accepting card-name aliases', () => {
|
||||
const effects = ['baliset', 'kulon', 'laLaLa', 'gamont', 'jubba', 'kull'];
|
||||
const purposes = effects.map(effect => kullCancellationPurpose('choamWorthless', effect));
|
||||
assert.equal(new Set(purposes).size, effects.length);
|
||||
assert.ok(purposes.every(isKullPurpose));
|
||||
for (const detail of [undefined, 'lalala', 'La, La, La!', 'Trip to Gamont', 'baliset:actor', 'constructor'])
|
||||
assert.throws(() => kullCancellationPurpose('choamWorthless', detail), /canonical CHOAM effect/);
|
||||
const history = [{ ...stamp, purpose: purposes[0] }];
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', purposes[0]), true);
|
||||
for (const purpose of purposes.slice(1)) assert.equal(kullBlocksKarama(history, 3, 5, 'actor', purpose), false);
|
||||
});
|
||||
|
||||
void test('special native variants share the actual faction key and special-force cancellation retains its owning faction', () => {
|
||||
for (const faction of FACTIONS) {
|
||||
const special = kullSpecialPurpose(faction.id);
|
||||
assert.equal(special, `special:${faction.id}`);
|
||||
assert.ok(isKullPurpose(special));
|
||||
const strength = kullCancellationPurpose('eliteStrength', faction.id);
|
||||
assert.equal(strength, `cancel:eliteStrength:${faction.id}`);
|
||||
assert.ok(isKullPurpose(strength));
|
||||
}
|
||||
const special = kullSpecialPurpose('emperor');
|
||||
const history = [{ ...stamp, purpose: special }];
|
||||
// The engine normalizes its native variants to their actual seated faction.
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', kullSpecialPurpose('emperor')), true);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', kullSpecialPurpose('guild')), false);
|
||||
assert.notEqual(kullCancellationPurpose('eliteStrength', 'emperor'), kullCancellationPurpose('eliteStrength', 'fremen'));
|
||||
for (const invalid of ['emperorRevival', 'emperorGift', 'Guild', 'unknown', 'constructor'])
|
||||
assert.throws(() => kullSpecialPurpose(invalid as FactionId), /actual native faction/);
|
||||
assert.throws(() => kullCancellationPurpose('eliteStrength'), /owner faction/);
|
||||
});
|
||||
|
||||
void test('ordinary cancellations use response semantics alone and reject route/card/private-value suffixes', () => {
|
||||
for (const kind of ['voice', 'guildIncome', 'guildRate', 'guildTransportPermission', 'nexusFremenCunning',
|
||||
'richeseNoField', 'richeseAuction', 'worthlessKarama'] as const) {
|
||||
assert.equal(kullCancellationPurpose(kind), `cancel:${kind}`);
|
||||
assert.ok(isKullPurpose(kullCancellationPurpose(kind)));
|
||||
assert.throws(() => kullCancellationPurpose(kind, 'private-card-or-route'), /without extra details/);
|
||||
}
|
||||
assert.throws(() => kullCancellationPurpose('unknown' as ResponseWindow['kind']), /canonical response/);
|
||||
assert.throws(() => kullPurposeLabel('special:unknown' as KullPurpose), /canonical purpose/);
|
||||
});
|
||||
|
||||
void test('Nexus aliases share their native purpose while distinct transport and worm powers remain separate', () => {
|
||||
for (const [alias, native] of [['nexusPrescience', 'prescience'], ['nexusAdvisorFlip', 'advisorFlip']] as const) {
|
||||
const purpose = kullCancellationPurpose(native);
|
||||
assert.equal(kullCancellationPurpose(alias), purpose);
|
||||
assert.equal(isKullPurpose(`cancel:${alias}`), false);
|
||||
assert.throws(() => validateKullPhaseRestrictions(context, [{ ...stamp, purpose: `cancel:${alias}` }]));
|
||||
assert.equal(kullBlocksKarama([{ ...stamp, purpose }], 3, 5, 'actor', kullCancellationPurpose(alias)), true);
|
||||
assert.throws(() => kullCancellationPurpose(alias, 'extra'), /without extra details/);
|
||||
}
|
||||
for (const [first, second] of [['guildRate', 'guildTransportPermission'], ['wormSurvival', 'wormAllyProtection']] as const) {
|
||||
const firstPurpose = kullCancellationPurpose(first), secondPurpose = kullCancellationPurpose(second);
|
||||
assert.notEqual(firstPurpose, secondPurpose);
|
||||
assert.ok(isKullPurpose(firstPurpose) && isKullPurpose(secondPurpose));
|
||||
assert.equal(kullBlocksKarama([{ ...stamp, purpose: firstPurpose }], 3, 5, 'actor', secondPurpose), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('purpose labels and projected blocks are readable and preserve legacy priority without suppressing another purpose', () => {
|
||||
assert.equal(kullPurposeLabel('shipment-price'), 'Karama shipment pricing');
|
||||
assert.equal(kullPurposeLabel('auction-free-purchase'), 'a free Treachery purchase');
|
||||
assert.equal(kullPurposeLabel(kullSpecialPurpose('emperor')), 'the Emperor special Karama power');
|
||||
assert.equal(kullPurposeLabel(kullCancellationPurpose('choamWorthless', 'baliset')), 'cancellation of Baliset');
|
||||
const view = { karamaPurposeBlocks: [
|
||||
{ purpose: 'shipment-price' as const, message: 'Shipment pricing is restricted this phase.' },
|
||||
{ purpose: 'cancel:voice' as const, message: 'Canceling Voice is restricted this phase.' },
|
||||
] };
|
||||
assert.equal(kullPurposeBlock(view, 'shipment-price'), view.karamaPurposeBlocks[0].message);
|
||||
assert.equal(kullPurposeBlock(view, 'cancel:voice'), view.karamaPurposeBlocks[1].message);
|
||||
assert.equal(kullPurposeBlock(view, 'special:emperor'), null);
|
||||
assert.equal(kullPurposeBlock({}, 'shipment-price'), null);
|
||||
assert.equal(kullPurposeBlock({ ...view, karamaBlocked: 'All Karama is restricted.' }, 'shipment-price'), 'All Karama is restricted.');
|
||||
assert.equal(kullPurposeBlock({ ...view, karamaBlocked: 'All Karama is restricted.' }, 'special:emperor'), 'All Karama is restricted.');
|
||||
});
|
||||
@@ -404,7 +404,7 @@ for (const source of ['printed', 'nexus'] as const)
|
||||
assert.equal(paid.players[0].spice, 1);
|
||||
});
|
||||
|
||||
void test('the Nexus-only Kull profile keeps unsupported Fremen remote Cunning behind its existing pre-offer guard', () => {
|
||||
void test('a declined Kull resumes the original Fremen special into its native Cunning offer', () => {
|
||||
const f = createNexusChoamKullFixture({ actorFaction: 'fremen', fuel: 'weapon', counter: false });
|
||||
const game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
|
||||
game.players[1].hand.push(takeKullCard(game, f.original));
|
||||
@@ -435,12 +435,19 @@ void test('the Nexus-only Kull profile keeps unsupported Fremen remote Cunning b
|
||||
const summoned = applyAction(reload(offered), f.choam, {
|
||||
type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
|
||||
assert.equal(summoned.players[1].specialKaramaUsed, true);
|
||||
assert.equal(summoned.nexusFremenCunningOffer ?? null, null);
|
||||
assert.ok(summoned.nexusFremenCunningOffer);
|
||||
assert.equal(summoned.nexusFremenCunningOffer.occurrence.parent, summoned.summonedWorm?.event);
|
||||
assert.equal(summoned.decision?.kind, 'nexusFremenCunningOffer');
|
||||
assert.equal(summoned.nexusFremenCunningRides ?? null, null);
|
||||
assert.equal(summoned.nexusCards!.cards!.hands[f.actor], 'fremen');
|
||||
assert.equal(summoned.discard.filter(card => card.id === f.original).length, 1);
|
||||
reject(summoned, f.actor, { type: 'decision', event: 'unsupported-remote-ride', accept: true });
|
||||
viewGame(reload(summoned), f.actor);
|
||||
const declined = applyAction(summoned, f.actor, {type:'decision',
|
||||
event:summoned.nexusFremenCunningOffer.occurrence.event, accept:false});
|
||||
assert.equal(declined.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(declined.nexusCards!.cards!.hands[f.actor], 'fremen');
|
||||
assert.equal(declined.discard.filter(card => card.id === f.original).length, 1);
|
||||
});
|
||||
|
||||
void test('a canceled CHOAM-effect block does not remove ordinary Karama auction-payment coverage', () => {
|
||||
|
||||
@@ -286,8 +286,9 @@ for (const advanced of [false, true])
|
||||
assert.equal(done.nexusChoamHistory![0].stage, 'complete');
|
||||
assert.deepEqual(done.nexusChoamLast, { event: nexusEvent, stage: 'complete' });
|
||||
assert.deepEqual((done.kullRestrictions ?? []).filter(rule => rule.player === actor),
|
||||
[{ player: actor, turn: done.turn, phase: done.phase }]);
|
||||
assert.ok(viewGame(done, actor).karamaBlocked, 'the stamped phase ban is publicly visible');
|
||||
[{ player: actor, turn: done.turn, phase: done.phase, ...(advanced ? {purpose: 'shipment-price'} : {}) }]);
|
||||
assert.equal(!!viewGame(done, actor).karamaBlocked, !advanced);
|
||||
assert.deepEqual(viewGame(done, actor).karamaPurposeBlocks.map(rule => rule.purpose), advanced ? ['shipment-price'] : []);
|
||||
assert.equal(viewGame(done, choam).kullReaction, null);
|
||||
await restored(f, done);
|
||||
|
||||
@@ -590,7 +591,8 @@ void test('SQLite completed Nexus Kull history stays intrinsic after later physi
|
||||
f.writes.length = 0;
|
||||
const views = await restored(f, later);
|
||||
assert.equal(views[0].kullReaction ?? null, null, 'a settled opportunity is never reopened by recycling');
|
||||
assert.ok(views[1].karamaBlocked, 'the stamped phase ban survives the later card movement');
|
||||
assert.equal(views[1].karamaBlocked, null);
|
||||
assert.deepEqual(views[1].karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price'], 'the effect restriction survives the later card movement');
|
||||
assert.equal(later.nexusChoamHistory!.length, 1, 'history never duplicates an event');
|
||||
assert.equal(later.nexusChoamHistory![0].stage, 'complete');
|
||||
assert.deepEqual(later.nexusChoamLast, { event: nexusEvent, stage: 'complete' });
|
||||
|
||||
Reference in new issue
Block a user