Offer Semuta on Richese cancellation costs

This commit is contained in:
Quintine committed 2026-10-25 23:56:09 +11:00
1 parent 8e6602633d
commit 0d868a3838
12 files changed
+957 -22

No files matched your search

+39
View File
@@ -16,6 +16,45 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**Semuta on Richese cancellation costs — 25 October. Prototyped / Partial.**
Printed-role Karama stopping the compulsory cache auction or allied Richese
gift now offers its exact used cost before native prevention finishes. Claim/
pass cannot undo that use, transfer the prevented gift, recount the ordinary
pool or reopen the cache opportunity. The native typed continuation preserves
cache stock/frozen count and independent Black Market reduction, or the exact
gift/recipient and suspended parent. Existing neutral controls, private choice,
full-hand exchange and minimal policies serve both sources. New tagged receipt
kinds bind those original sources; no producer action or cost discard replays.
[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#richese-cancellation-costs--25-october-2026).
Two disjoint native-source lanes, bounded read-only review and root integration
pass **20 files,235/235**, types/lint/build and **20 actual native engine/
GameTable SSR/real Semuta-callback journeys** on unchanged source. Twelve cache
and ten gift cases cover Basic/Advanced claim/pass, first/last cache, actual
Black Market reduction, full4 exchange, pending EmperorIncome1, stale/foreign/
underlying replies and all four minimal profiles. Cache prefixes94/101 earn
Semuta through paid cache1; Advanced108 actually sells an original Black Market
card for1 and restores frozen ordinary3 rather than4. Gift prefixes121/128
earn both cache cards through real Silent1/zero submissions and accept actual
Worm2 alliance. Parent116/123 retains native income; full130/137 buys normal1
and keeps hand4 after exchange. All45 physical cards remain; no played hand,
wallet, force, clock, alliance or pending frame is installed.
Source feedback uses Silent for the paid Richese self-purchases because Once
Around closes automatically when only its owner remains. Related206/219 finds
older Ix/Moritani producers skipping the shared final battle readiness. Their
drivers now submit the real owned readiness actions before cleanup; mechanical
substitution/retention assertions stay. Types require explicit reaction/card
narrowing only. The stale repeat-cache warning is corrected from current native
guards, without a blanket ban on later unrelated Karama. Existing gift-retry
protection remains. Private report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261025-semuta-richese-reaction-typed/`;
journeys/HTML: adjacent `20261025-semuta-richese-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
These are engine/SSR/callback results, not hydration, DOM/browser/mobile or
deployed evidence. Other printed cancellation producers, broader valid
combinations, complete modes and final assurance remain unfinished.
**Sapho behind a started combined turn — 25 October. Prototyped / Partial.** **Sapho behind a started combined turn — 25 October. Prototyped / Partial.**
An unstarted holder may now provisionally reorder only later unstarted turns An unstarted holder may now provisionally reorder only later unstarted turns
behind another actor's begun combined turn. That actor finishes unchanged; behind another actor's begun combined turn. That actor finishes unchanged;
+42
View File
@@ -1,5 +1,47 @@
# Dune implementation status # Dune implementation status
## 25 October 2026 — Semuta claims Richese cancellation costs
**Prototyped / Partial.** Exact physical printed-role Karama stopping the
compulsory Richese cache auction or allied gift now enters the existing neutral
Semuta protocol before native prevention. Claim/pass resumes the original typed
response once. Cache/deck/order and frozen before-cache count, including actual
Black Market reduction, remain; no recount after spending or canceled cache
offer is invented. Prevented gift stays with Richese, original recipient and
parent remain, and existing prevention history applies once. New tagged gift
exceptions bind that exact live frame; other source validators retain authority.
No producer action or activation discard replays. Generic persistent controls,
private candidate selection, full-hand exchange and minimal legal bots are reused.
[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#richese-cancellation-costs--25-october-2026).
Two disjoint source lanes, bounded review and root integration pass **20 files,
235/235**, types/lint/build and twenty native engine/GameTable SSR/actual Semuta
button-callback journeys on unchanged source. Cache12/gift10 cases cover both
rules bands, claim/pass, first/last, Black Market count, full4 exchange, original
EmperorIncome1 parent, relevant rejection and all four minimal profiles.
Cache94/101 source actions earn paid1 Semuta; Advanced108 includes a real sold
Black Market card and restores frozen3. Gift121/128 earns Semuta and Distrans
through actual Silent1/zero bids and real Worm2 alliance. Parent116/123 keeps
the original paid cache-income response. Full130/137 additionally buys normal1,
then exchanges Semuta at hand4. All45 cards remain physical; no played-state
custody, balances, armies, clock, alliance or pending record is assigned.
First gift source learns that Once Around closes its owner-only unbid remainder;
Silent supplies the genuine paid self-purchases. Related206/219 catches older
Ix/Moritani drivers bypassing the shared final battle readiness. They now use
both original readiness actions before cleanup, keeping mechanical assertions.
Test typing narrows the live reaction and held normal card. The old decision
row claiming repeat cache cancellation is reconciled with the current uncanceled-
declaration guard; a later Karama may legitimately target another response.
Gift retry policy is unchanged. No production rule/start guard is weakened.
Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261025-semuta-richese-reaction-typed/`;
journeys/HTML: adjacent `20261025-semuta-richese-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
These are engine/SSR/callback results; hydration, DOM/browser/mobile, broader
combinations, full modes and deployed/final assurance remain open.
## 25 October 2026 — Sapho reorders the waiting tail behind a started turn ## 25 October 2026 — Sapho reorders the waiting tail behind a started turn
**Prototyped / Partial.** An unstarted different Sapho holder may provisionally **Prototyped / Partial.** An unstarted different Sapho holder may provisionally
+1 -1
View File
@@ -1362,7 +1362,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifiers, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Owned multi-pile Smuggler/Sandmaster allocation and printed alternate/hybrid defense roles now have connected, explicitly interpreted paths. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) | | Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifiers, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Owned multi-pile Smuggler/Sandmaster allocation and printed alternate/hybrid defense roles now have connected, explicitly interpreted paths. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
| Smuggler collection strength | The older source check/question produced no numerical answer. Under the later choose-recommended instruction, provisionally cap by copied/printed disc plus declared skill bonuses and KH2, retaining reveal piles and delayed survival settlement. Actual Artillery zeroing voids collection; the base Stunner remains a projectile. Sole own Traitor bypasses weapons, so it retains collection. Printed bounty does not change; this is not a publisher ruling. Other exceptional compositions remain separate. | [Source and explicit policy](LEADER_SKILLS_RULES.md#smuggler-modifier-policy--17-october-2026), [runtime contract](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) | | Smuggler collection strength | The older source check/question produced no numerical answer. Under the later choose-recommended instruction, provisionally cap by copied/printed disc plus declared skill bonuses and KH2, retaining reveal piles and delayed survival settlement. Actual Artillery zeroing voids collection; the base Stunner remains a projectile. Sole own Traitor bypasses weapons, so it retains collection. Printed bounty does not change; this is not a publisher ruling. Other exceptional compositions remain separate. | [Source and explicit policy](LEADER_SKILLS_RULES.md#smuggler-modifier-policy--17-october-2026), [runtime contract](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) | | Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Repeat Karama on a canceled compulsory cache lot | After one Karama cancels the compulsory Richese cache auction and restores the ordinary pool, the engine currently still accepts a second Karama against the resumed window and re-derives the ordinary count from the frozen pre-cache field. Whether a repeat cancellation should be refused is not settled; the invariant that a canceled lot never restores more than its frozen pre-cache count does hold. | [Richese auction source](RICHESE_AUCTION_RULES.md), [exhausted-cache runtime](IMPLEMENTATION_STATUS.md) | | Repeat Karama on a canceled compulsory cache lot | The earlier runtime observation is superseded by current source: cache cancellation requires an uncanceled declaration with no dealt pool; completion marks canceled and advances to normal. A second original cache cancellation is unavailable. A later Karama may target a different response, including Atreides inspection; no blanket second-card prohibition or publisher repeat ruling is inferred. The frozen before-cache count and independent sale reduction remain. | [Richese auction source](RICHESE_AUCTION_RULES.md), [current cancellation-cost source](SEMUTA_SOURCE_UPDATE_20260907.md#richese-cancellation-costs--25-october-2026) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Supplied physical27 F now sets its before-public-reveal deadline, including quiet later use after declining the initial offer. Ordinary preparation priority and target-first sequencing remain implementation compositions, with sealed plans retained and no added reading acknowledgement. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [current full-plan timing](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026) | | Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Supplied physical27 F now sets its before-public-reveal deadline, including quiet later use after declining the initial offer. Ordinary preparation priority and target-first sequencing remain implementation compositions, with sealed plans retained and no added reading acknowledgement. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [current full-plan timing](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026) |
| Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and other entry effects retain their documented boundaries. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) | | Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and other entry effects retain their documented boundaries. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) |
| Revived force arrival / BG | Advanced Intrusion follows supplied p28 entry wording. Provisionally treat off-planet Tleilax SEND to Arrakis as earning ordinary BG Spiritual accompaniment; Southern is on-planet and Homeworld destinations are separate. Keep committed return/placement, ordinary counters, original income and one deferred Ambassador entry; add no fee/ordinary allowance/Guild stop/phase4 industry. Phase4 Spiritual binds the exact return or owned Guild child. The later Terror continuation retains the original return, ordered Ambassador opportunity and actual Sneak children; complete combinations remain unfinished. | [Current source and policy](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026) | | Revived force arrival / BG | Advanced Intrusion follows supplied p28 entry wording. Provisionally treat off-planet Tleilax SEND to Arrakis as earning ordinary BG Spiritual accompaniment; Southern is on-planet and Homeworld destinations are separate. Keep committed return/placement, ordinary counters, original income and one deferred Ambassador entry; add no fee/ordinary allowance/Guild stop/phase4 industry. Phase4 Spiritual binds the exact return or owned Guild child. The later Terror continuation retains the original return, ordered Ambassador opportunity and actual Sneak children; complete combinations remain unfinished. | [Current source and policy](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026) |
+69
View File
@@ -2,6 +2,75 @@
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits. 7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
## Richese cancellation costs — 25 October 2026
**Prototyped / Partial.** Printed-role Karama stopping the compulsory Richese
cache auction or an allied Richese card gift now offers that exact used cost
through the original neutral Semuta response. This follows the existing used-
card timing convention: disposal precedes the native prevention continuation.
Claim/pass moves only the cost; it does not undo its use or replay the attempt.
The generic persistent controls, exact private candidate choice and minimal
legal profiles remain. A Worthless conversion retains its separate existing
lane; this is not new conversion immunity or Kull admission.
`karamaRicheseAuctionDiscard` binds the original undealt declaration, frozen
before-cache/ordinary counts, independent completed Black Market reduction,
cache/deck/order and source controls. After claim/pass, native prevention marks
the cache canceled and prepares the original ordinary pool once. It neither
recounts capacity after Karama leaves a hand nor removes a cached card.
`karamaRicheseGiftDiscard` binds the exact pending private gift/recipient,
existing prevention history and native suspended parent. The donor retains the
prevented card; the recipient gets none. Original cancellation history and
parent restoration execute once through the existing typed Karama continuation.
Recovering the spent cost cannot transfer that gift, cancel its prevention or
replay a paid purchase/income. The gift face remains within existing ally-only
inspection. New tagged exceptions permit only this original live gift frame;
the native gift/cancellation validators remain authoritative.
The old decision-index observation about a second cache cancellation was stale:
current validation already requires an uncanceled declaration with no dealt
pool; completion marks canceled and advances its stage. A later Karama may
legitimately target a different native response, such as Atreides inspection.
No blanket prohibition or newly discovered publisher repeat ruling is added.
Existing same-card gift retry protection is unchanged.
Evidence: [cache source](../tests/fixture-semuta-richese-cache-karama.ts),
[cache cases](../tests/semuta-richese-cache-karama-engine.test.ts),
[gift source](../tests/fixture-semuta-richese-gift-karama.ts),
[gift cases](../tests/semuta-richese-gift-karama-engine.test.ts).
Full combinations, recovery/privacy/custody assurance and deployed acceptance
remain open; this does not certify complete Semuta or faction modes.
Frozen **20 files,235/235**, types/lint/build and twenty actual native engine/
GameTable SSR/real Semuta callback journeys pass on unchanged source. New
cache12/gift10 cases cover both rules bands, first/last, claim/pass, actual
Black Market reduction, full4 exchange, an original EmperorIncome1 parent,
underlying/stale/foreign rejection and all four minimal profiles.
Native cache prefixes94 Basic/101 Advanced earn Semuta through a paid cache1
purchase and reach a later original declaration; Advanced108 actually sells
an original Black Market card for1, retaining its frozen3 count. Gift121/128
earns Semuta and Distrans through two real Silent auctions (Richese1, others0)
and accepts an actual Worm2 alliance. Parent116/123 keeps the native paid-cache
income1 response; full130/137 adds a real normal1 purchase and exchanges at
hand4. Initial deal and unopened stock/lotteries are selected explicitly;
no played-state custody, resource, clock, alliance or receipt is installed.
Original45-card inventory remains physical in all twenty journeys.
First gift source feedback selects Silent because native Once Around closes
an owner-only unbid remainder; no paid self-bid is fabricated. Related206/219
reveals older Ix/Moritani fixtures stopping before shared final battle readiness.
They now submit both real owned readiness actions before original cleanup,
retaining all substitution/retention assertions. Test-only reaction/card typing
is corrected. No production rule or guard is weakened for a green check.
Private matching source-bound report:
`/home/quintine/.local/share/dune/checkpoints/20261025-semuta-richese-reaction-typed/report.json`;
journeys and HTML in adjacent `20261025-semuta-richese-runtime/`.
No saved-room/database/server/reset/restart/deployment/recovery operation occurs.
Engine/SSR/callback evidence does not certify hydration, DOM/browser/mobile,
full combinations, complete modes or exact-revision deployed acceptance.
## Basic shared Ambassador and Terror arrival — 24 October 2026 ## Basic shared Ambassador and Terror arrival — 24 October 2026
**Prototyped / Partial.** The existing bare Basic Richese profile now admits **Prototyped / Partial.** The existing bare Basic Richese profile now admits
+63 -20
View File
@@ -1575,7 +1575,7 @@ export type Game = {
stateSignature: string; stateSignature: string;
} }
| { | {
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard'; kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard';
player: string; player: string;
card: string; card: string;
use: Extract<KaramaUse, { kind: 'cancel' }>; use: Extract<KaramaUse, { kind: 'cancel' }>;
@@ -5812,9 +5812,23 @@ function bgKaramaCostDiscardSignature(g: Game, c: BgKaramaCostDiscardContinuatio
})), })),
}); });
} }
/** The original native validators own source legality. This classifier only
* identifies the two Richese cancellation costs for the shared discard lane. */
function semutaRicheseKaramaResponse(g: Game, response: ResponseWindow): boolean {
if (response.kind !== 'richeseAuction' && response.kind !== 'richeseGift') return false;
try {
if (response.kind === 'richeseGift')
return currentRicheseCancellationQuote(g,response)?.kind === 'richeseGift';
validateRicheseCacheCancellation(g,response);
return true;
} catch(error) {
if (error instanceof RuleError) return false;
throw error;
}
}
type KaramaResponseDiscardContinuation = Extract< type KaramaResponseDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'], NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' } { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' }
>; >;
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
const battle = c.kind === 'karamaBattleDiscard'; const battle = c.kind === 'karamaBattleDiscard';
@@ -5823,6 +5837,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
const income = c.kind === 'karamaIncomeDiscard'; const income = c.kind === 'karamaIncomeDiscard';
const shipment = c.kind === 'karamaShipmentDiscard'; const shipment = c.kind === 'karamaShipmentDiscard';
const conversion = c.kind === 'karamaBgConversionCancelDiscard'; const conversion = c.kind === 'karamaBgConversionCancelDiscard';
const richese = c.kind === 'karamaRicheseAuctionDiscard' || c.kind === 'karamaRicheseGiftDiscard';
return JSON.stringify({ return JSON.stringify({
player: c.player, card: c.card, use: c.use, player: c.player, card: c.card, use: c.use,
parent: nullentropyParentSignature(g, c.resume, parent: nullentropyParentSignature(g, c.resume,
@@ -5871,6 +5886,14 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
homeworlds: g.homeworlds, rate: g.guildRateBlocked, permission: g.guildTransportBlocked, homeworlds: g.homeworlds, rate: g.guildRateBlocked, permission: g.guildTransportBlocked,
auction: g.auction, richeseAuction: g.richeseAuction, auction: g.auction, richeseAuction: g.richeseAuction,
} : undefined, } : undefined,
richese: richese ? {
round:g.richeseBidding, cache:g.richeseCache, deck:g.deck,
auction:g.auction, lot:g.richeseAuction, ix:g.ixAuction,
sale:g.currentAuctionSale, gift:g.pendingRicheseGift, blocked:g.richeseGiftBlocked,
purchaseIncome:g.pendingRichesePurchaseIncome, aid:g.aid,
active:g.active, ready:g.ready, remaining:g.movementRemaining,
shipping:g.karamaShipping, battle:g.battle,
} : undefined,
order: g.order, order: g.order,
players: g.players.map(p => ({ players: g.players.map(p => ({
id: p.id, faction: p.faction, ally: p.ally, id: p.id, faction: p.faction, ally: p.ally,
@@ -6450,11 +6473,11 @@ function treacheryDiscardIntegrity(g: Game) {
!g.decision && !g.decision &&
!g.truthtrance && !g.truthtrance &&
!g.phaseOpening && !g.phaseOpening &&
(!g.pendingRicheseGift || box || truth) && (!g.pendingRicheseGift || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard') &&
!g.pendingKarama && !g.pendingKarama &&
!g.pendingNullentropy && (!g.pendingNullentropy || continuation?.kind === 'karamaRicheseGiftDiscard') &&
(!g.pendingAmbassador || box || truth || ambassadorTerror) && (!g.pendingAmbassador || box || truth || ambassadorTerror || continuation?.kind === 'karamaRicheseGiftDiscard') &&
(!g.pendingIxAlly || box || truth), (!g.pendingIxAlly || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard'),
'The saved discard does not match its current continuation.', 'The saved discard does not match its current continuation.',
); );
const ids = new Set<string>(); const ids = new Set<string>();
@@ -6787,6 +6810,8 @@ function treacheryDiscardIntegrity(g: Game) {
continuation?.kind === 'karamaChoamWorthlessDiscard' || continuation?.kind === 'karamaChoamWorthlessDiscard' ||
continuation?.kind === 'karamaEmperorShareDiscard' || continuation?.kind === 'karamaEmperorShareDiscard' ||
continuation?.kind === 'karamaIncomeDiscard' || continuation?.kind === 'karamaIncomeDiscard' ||
continuation?.kind === 'karamaRicheseAuctionDiscard' ||
continuation?.kind === 'karamaRicheseGiftDiscard' ||
continuation?.kind === 'karamaShipmentDiscard' || continuation?.kind === 'karamaShipmentDiscard' ||
continuation?.kind === 'karamaBgConversionCancelDiscard') { continuation?.kind === 'karamaBgConversionCancelDiscard') {
const c = continuation, entry = batch.entries[0]; const c = continuation, entry = batch.entries[0];
@@ -6811,6 +6836,10 @@ function treacheryDiscardIntegrity(g: Game) {
semutaEmperorShareKaramaResponse(g, c.use.response)) || semutaEmperorShareKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaIncomeDiscard' && (c.kind === 'karamaIncomeDiscard' &&
semutaIncomeKaramaResponse(g, c.use.response)) || semutaIncomeKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaRicheseAuctionDiscard' && c.use.response.kind === 'richeseAuction' &&
semutaRicheseKaramaResponse(g,c.use.response)) ||
(c.kind === 'karamaRicheseGiftDiscard' && c.use.response.kind === 'richeseGift' &&
semutaRicheseKaramaResponse(g,c.use.response)) ||
(c.kind === 'karamaShipmentDiscard' && (c.kind === 'karamaShipmentDiscard' &&
semutaGuildShipmentKaramaResponse(g, c.use.response)) || semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaBgConversionCancelDiscard' && (c.kind === 'karamaBgConversionCancelDiscard' &&
@@ -6841,6 +6870,8 @@ function treacheryDiscardIntegrity(g: Game) {
else if (c.use.response.kind === 'richesePurchaseIncome') else if (c.use.response.kind === 'richesePurchaseIncome')
currentRicheseCancellationQuote(g, c.use.response); currentRicheseCancellationQuote(g, c.use.response);
} }
if (c.kind === 'karamaRicheseAuctionDiscard') validateRicheseCacheCancellation(g,c.use.response);
if (c.kind === 'karamaRicheseGiftDiscard') currentRicheseCancellationQuote(g,c.use.response);
if (c.kind === 'karamaChoamWorthlessDiscard') { if (c.kind === 'karamaChoamWorthlessDiscard') {
try { quoteChoamWorthlessCancellation(g, c.use.response); } try { quoteChoamWorthlessCancellation(g, c.use.response); }
catch (error) { catch (error) {
@@ -7708,6 +7739,8 @@ function semutaOfferSupported(
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'], continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'], entries: FreshDiscardBatch['entries'],
) { ) {
const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard';
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' || const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
continuation.kind === 'karamaBgConversionCancelDiscard'; continuation.kind === 'karamaBgConversionCancelDiscard';
const ambassadorDiscard = continuation.kind === 'ambassador'; const ambassadorDiscard = continuation.kind === 'ambassador';
@@ -7746,6 +7779,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaChoamWorthlessDiscard' && continuation.kind !== 'karamaChoamWorthlessDiscard' &&
continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaEmperorShareDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaIncomeDiscard' &&
!richeseDiscard &&
continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'karamaShipmentDiscard' &&
continuation.kind !== 'karamaShipmentPriceDiscard' && continuation.kind !== 'karamaShipmentPriceDiscard' &&
continuation.kind !== 'specialKaramaDiscard' && continuation.kind !== 'specialKaramaDiscard' &&
@@ -7782,7 +7816,7 @@ function semutaOfferSupported(
continuation.promise.kind === 'shipment' || continuation.promise.kind === 'shipment' ||
continuation.promise.kind === 'movement' || continuation.promise.kind === 'movement' ||
continuation.promise.kind === 'battle'))) return false; continuation.promise.kind === 'battle'))) return false;
if (g.homeworlds && !bgDiscard && continuation.kind !== 'kaitainDiscard' && if (g.homeworlds && !bgDiscard && !richeseDiscard && continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'tupileCleanupDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaIncomeDiscard' &&
continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'karamaShipmentDiscard' &&
@@ -7884,6 +7918,7 @@ function semutaOfferSupported(
continuation.kind === 'karamaChoamWorthlessDiscard' || continuation.kind === 'karamaChoamWorthlessDiscard' ||
continuation.kind === 'karamaEmperorShareDiscard' || continuation.kind === 'karamaEmperorShareDiscard' ||
continuation.kind === 'karamaIncomeDiscard' || continuation.kind === 'karamaIncomeDiscard' ||
richeseDiscard ||
continuation.kind === 'karamaShipmentDiscard' continuation.kind === 'karamaShipmentDiscard'
? (continuation.kind === 'karamaBattleDiscard' ? (continuation.kind === 'karamaBattleDiscard'
? semutaBattleKaramaResponse(g, continuation.use.response) ? semutaBattleKaramaResponse(g, continuation.use.response)
@@ -7893,6 +7928,8 @@ function semutaOfferSupported(
? semutaEmperorShareKaramaResponse(g, continuation.use.response) ? semutaEmperorShareKaramaResponse(g, continuation.use.response)
: continuation.kind === 'karamaIncomeDiscard' : continuation.kind === 'karamaIncomeDiscard'
? semutaIncomeKaramaResponse(g, continuation.use.response) ? semutaIncomeKaramaResponse(g, continuation.use.response)
: richeseDiscard
? semutaRicheseKaramaResponse(g,continuation.use.response)
: continuation.kind === 'karamaShipmentDiscard' : continuation.kind === 'karamaShipmentDiscard'
? semutaGuildShipmentKaramaResponse(g, continuation.use.response) ? semutaGuildShipmentKaramaResponse(g, continuation.use.response)
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
@@ -7926,6 +7963,7 @@ function semutaOfferSupported(
continuation.kind === 'karamaChoamWorthlessDiscard' || continuation.kind === 'karamaChoamWorthlessDiscard' ||
continuation.kind === 'karamaEmperorShareDiscard' || continuation.kind === 'karamaEmperorShareDiscard' ||
continuation.kind === 'karamaIncomeDiscard' || continuation.kind === 'karamaIncomeDiscard' ||
richeseDiscard ||
continuation.kind === 'karamaShipmentDiscard' || continuation.kind === 'karamaShipmentDiscard' ||
((continuation.kind === 'ordinaryCardDiscard' ((continuation.kind === 'ordinaryCardDiscard'
? ordinaryDiscardIncomeMatches(g, continuation) ? ordinaryDiscardIncomeMatches(g, continuation)
@@ -7936,9 +7974,9 @@ function semutaOfferSupported(
(continuation.kind === 'amalDiscard' (continuation.kind === 'amalDiscard'
? continuation.resume.phaseOpening?.passed.length === 0 ? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening)))) && : !continuation.resume.phaseOpening)))) &&
(continuation.kind === 'karamaShipmentDiscard' || (richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) && continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) &&
(bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard' (richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin && ? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin &&
(continuation.sale.origin === 'normal' (continuation.sale.origin === 'normal'
? !!g.auction && !g.richeseAuction ? !!g.auction && !g.richeseAuction
@@ -7952,11 +7990,11 @@ function semutaOfferSupported(
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal' ? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction) ? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
: (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) && : (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
(bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || (richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
continuation.kind === 'ixAllyCard' || continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaIncomeDiscard' && (continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) && continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' || (richeseGiftDiscard || continuation.kind === 'saphoAggressorDiscard' ||
bgDiscard || bgDiscard ||
continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'specialKaramaDiscard' ||
continuation.kind === 'residualPoisonDiscard' || continuation.kind === 'residualPoisonDiscard' ||
@@ -7979,22 +8017,22 @@ function semutaOfferSupported(
(!!g.stormResolution && !g.pendingChoamMove && (!!g.stormResolution && !g.pendingChoamMove &&
!g.pendingArrivalOverlap && !g.pendingRevival && !g.pendingArrivalOverlap && !g.pendingRevival &&
!g.ornithopter && !g.ecologicalStorm)) && !g.ornithopter && !g.ecologicalStorm)) &&
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && (richeseGiftDiscard || (!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingTerrorEntry)) &&
(continuation.kind === 'karamaShipmentDiscard' || (richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'specialKaramaDiscard' ||
bgDiscard || bgDiscard ||
(!g.pendingShipment && !g.pendingHomeworldShipment)) && (!g.pendingShipment && !g.pendingHomeworldShipment)) &&
!g.pendingRicheseGift && (richeseGiftDiscard || !g.pendingRicheseGift) &&
((continuation.kind === 'karamaIncomeDiscard' && (richeseGiftDiscard || (continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) && continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) &&
(continuation.kind === 'choamSaleDiscard' (richeseGiftDiscard || (continuation.kind === 'choamSaleDiscard'
? !!g.choamMarket && !g.choamMarket.sale && ? !!g.choamMarket && !g.choamMarket.sale &&
!g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale !g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale
: continuation.kind === 'kaitainDiscard' : continuation.kind === 'kaitainDiscard'
? g.biddingEnd?.event === continuation.event && biddingEndError(g) === null ? g.biddingEnd?.event === continuation.event && biddingEndError(g) === null
: !g.choamMarket) && : !g.choamMarket)) &&
!g.pendingChoamMarketGhola; (richeseGiftDiscard || !g.pendingChoamMarketGhola);
} }
function semutaContext(g: Game, owner: Player): SemutaContext { function semutaContext(g: Game, owner: Player): SemutaContext {
const pending = g.pendingTreacheryDiscard!; const pending = g.pendingTreacheryDiscard!;
@@ -8252,6 +8290,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
next.kind === 'karamaChoamWorthlessDiscard' || next.kind === 'karamaChoamWorthlessDiscard' ||
next.kind === 'karamaEmperorShareDiscard' || next.kind === 'karamaEmperorShareDiscard' ||
next.kind === 'karamaIncomeDiscard' || next.kind === 'karamaIncomeDiscard' ||
next.kind === 'karamaRicheseAuctionDiscard' ||
next.kind === 'karamaRicheseGiftDiscard' ||
next.kind === 'karamaShipmentDiscard' || next.kind === 'karamaShipmentDiscard' ||
next.kind === 'karamaBgConversionCancelDiscard' || next.kind === 'karamaBgConversionCancelDiscard' ||
next.kind === 'karamaPurchaseDiscard' || next.kind === 'karamaPurchaseDiscard' ||
@@ -15604,10 +15644,13 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) || use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) ||
semutaChoamKaramaResponse(g, use.response) || semutaChoamKaramaResponse(g, use.response) ||
semutaEmperorShareKaramaResponse(g, use.response) || semutaEmperorShareKaramaResponse(g, use.response) ||
semutaIncomeKaramaResponse(g, use.response) || guildShipmentDiscard || bgConversionCancelDiscard)) { semutaIncomeKaramaResponse(g, use.response) || semutaRicheseKaramaResponse(g,use.response) ||
guildShipmentDiscard || bgConversionCancelDiscard)) {
const continuation: KaramaResponseDiscardContinuation = { const continuation: KaramaResponseDiscardContinuation = {
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard' kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
: guildShipmentDiscard ? 'karamaShipmentDiscard' : guildShipmentDiscard ? 'karamaShipmentDiscard'
: use.response.kind === 'richeseAuction' ? 'karamaRicheseAuctionDiscard'
: use.response.kind === 'richeseGift' ? 'karamaRicheseGiftDiscard'
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' : use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
: use.response.kind === 'choamInflation' : use.response.kind === 'choamInflation'
? 'karamaInflationDiscard' ? 'karamaInflationDiscard'
+5
View File
@@ -1287,6 +1287,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A mandatory loser-card discard may also offer Semuta while the original winner’s compulsory used card remains reserved. For native Ixians, claim or Continue settles the original casualties, then restores the one-for-one Suboid/Cyborg substitution and its Karama response. The winning used Poison Tooth is not discarded until substitution or decline finishes; only then is that physical Tooth a separate fresh target before optional winner cleanup.', 'A mandatory loser-card discard may also offer Semuta while the original winner’s compulsory used card remains reserved. For native Ixians, claim or Continue settles the original casualties, then restores the one-for-one Suboid/Cyborg substitution and its Karama response. The winning used Poison Tooth is not discarded until substitution or decline finishes; only then is that physical Tooth a separate fresh target before optional winner cleanup.',
'In the Basic native Moritani entry, winner compulsory/optional card discards may pause before the losing ally’s original retention choice. After that ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch then offers Semuta. A kept or unrelated hand card is never a target. Multiple fresh cards require commitment and one private selection; taking a winner’s card does not add it to the original retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.', 'In the Basic native Moritani entry, winner compulsory/optional card discards may pause before the losing ally’s original retention choice. After that ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch then offers Semuta. A kept or unrelated hand card is never a target. Multiple fresh cards require commitment and one private selection; taking a winner’s card does not add it to the original retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.',
'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.', 'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.',
'A printed-role Karama stopping Richese’s compulsory cache auction or allied card gift also offers its exact used cost before prevention completes. Claim or Continue resumes that original prevention once. The cache keeps its cards and frozen ordinary count, including any completed Black Market reduction; a prevented gift stays with Richese and its original parent resumes. Recovering Karama does not repeat the attempt, transfer the gift or replay a paid purchase. Existing gift retry protection remains.',
'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.', 'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.',
'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.', 'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.',
'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.', 'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.',
@@ -1412,6 +1413,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-homeworld-battle-sapho.test.ts', 'tests/semuta-homeworld-battle-sapho.test.ts',
'tests/semuta-homeworld-economic-karama.test.ts', 'tests/semuta-homeworld-economic-karama.test.ts',
'tests/semuta-homeworld-karama-auction.test.ts', 'tests/semuta-homeworld-karama-auction.test.ts',
'tests/fixture-semuta-richese-cache-karama.ts',
'tests/semuta-richese-cache-karama-engine.test.ts',
'tests/fixture-semuta-richese-gift-karama.ts',
'tests/semuta-richese-gift-karama-engine.test.ts',
'tests/semuta-homeworld-bg-charity.test.ts', 'tests/semuta-homeworld-bg-charity.test.ts',
'tests/semuta-homeworld-emperor-share.test.ts', 'tests/semuta-homeworld-emperor-share.test.ts',
'tests/semuta-homeworld-capture-karama.test.ts', 'tests/semuta-homeworld-capture-karama.test.ts',
+2 -1
View File
@@ -4,6 +4,7 @@ import { botActions } from '../game/bots';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import type { Difficulty } from '../game/bot-profiles'; import type { Difficulty } from '../game/bot-profiles';
import type { Card } from '../game/cards'; import type { Card } from '../game/cards';
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
export type SemutaMoritaniStep = { actor: string; action: Action }; export type SemutaMoritaniStep = { actor: string; action: Action };
export type SemutaMoritaniOptions = { winnerMandatory?: boolean; karamaHolder?: 'r' | 'm' | 'w' | 'l'; semutaHolder?: 'r' | 'l' }; export type SemutaMoritaniOptions = { winnerMandatory?: boolean; karamaHolder?: 'r' | 'm' | 'w' | 'l'; semutaHolder?: 'r' | 'l' };
@@ -115,7 +116,7 @@ export function resolveSemutaMoritaniBattle(f: SemutaMoritaniFixture, actions?:
game = semutaMoritaniAct(game, { actor: 'l', action: { type: 'battlePlan', dial: 0, support: 0, game = semutaMoritaniAct(game, { actor: 'l', action: { type: 'battlePlan', dial: 0, support: 0,
leader: f.loserLeader, weapon: f.weapon, defense: f.defense } }, actions); leader: f.loserLeader, weapon: f.weapon, defense: f.defense } }, actions);
for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions); for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
return game; return finishSemutaBattleOutcome(game, actions);
} }
export function passSemutaMoritaniDiscard(game: Game, actions?: SemutaMoritaniStep[]): Game { export function passSemutaMoritaniDiscard(game: Game, actions?: SemutaMoritaniStep[]): Game {
const event = game.pendingTreacheryDiscard!.batch.event; const event = game.pendingTreacheryDiscard!.batch.event;
+2
View File
@@ -9,6 +9,7 @@ import {
finishSemutaMoritaniWinner, chooseSemutaMoritaniRetention, finishSemutaMoritaniWinner, chooseSemutaMoritaniRetention,
type SemutaMoritaniFixture, type SemutaMoritaniFixture,
} from './fixture-semuta-moritani-retention'; } from './fixture-semuta-moritani-retention';
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
export type SemutaNativeKaramaStep = { actor: string; action: Action }; export type SemutaNativeKaramaStep = { actor: string; action: Action };
export type SemutaNativeKaramaFixture = { export type SemutaNativeKaramaFixture = {
@@ -101,6 +102,7 @@ export function semutaNativeKaramaPreCost(f: SemutaNativeKaramaFixture, actions?
game = semutaNativeKaramaAct(game, { actor: ix.winner, action: { type: 'decision', activate: true } }, actions); game = semutaNativeKaramaAct(game, { actor: ix.winner, action: { type: 'decision', activate: true } }, actions);
for (const actor of [ix.winner, ix.loser]) for (const actor of [ix.winner, ix.loser])
game = semutaNativeKaramaAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions); game = semutaNativeKaramaAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
game = finishSemutaBattleOutcome(game, actions);
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [ix.snooper]); assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [ix.snooper]);
game = semutaNativeKaramaReact(game, f, false, actions); game = semutaNativeKaramaReact(game, f, false, actions);
assert.equal(game.decision?.kind, 'ixSubstitution'); assert.equal(game.decision?.kind, 'ixSubstitution');
@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
} from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actTruthMovement } from './fixture-truth-movement';
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
export type SemutaRicheseCacheKaramaStep = { actor: string; action: Action };
export const actSemutaRicheseCacheKarama = actTruthMovement;
export const semutaRicheseCacheKaramaPlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const semutaRicheseCacheKaramaClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function semutaRicheseCacheKaramaInventory(game: Game): string[] {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Every original main/cache card has one physical location');
return ids;
}
/** Real native decisions only. Turn one buys the actual Semuta for one;
* subsequent original lots receive passes rather than installed outcomes. */
export function nextSemutaRicheseCacheKarama(game: Game): SemutaRicheseCacheKaramaStep | null {
if (game.truthtrance) return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'choamMarket' || game.decision?.kind === 'choamMentat')
return { actor: game.decision.player, action: { type: 'decision', done: true } };
if (!game.decision && game.biddingEnd) {
const end = game.biddingEnd, actor = end.owners.find(id => !end.ready.includes(id));
if (actor) return { actor, action: { type: 'biddingEnd', event: end.event, mode: 'ready' } };
}
if (!game.decision && game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
}
const lot = game.richeseAuction;
if (semutaRicheseCacheKaramaClean(game) && game.turn === 1 && lot?.cardId === SEMUTA_DRUG_ID &&
!lot.outcome && lot.active === 'choam' && lot.bid === 0)
return { actor: 'choam', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
return nextTruthMovementNativeEarlyStep(game, {
position: 'first', method: 'onceAround', direction: 'clockwise',
card: game.richeseCache?.find(card => card.id === SEMUTA_DRUG_ID)?.id ?? game.richeseCache?.[0]?.id,
keepUnbid: false,
});
}
export function advanceSemutaRicheseCacheKarama(game: Game, until: (state: Game) => boolean,
actions: SemutaRicheseCacheKaramaStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native cache-cancellation source must reach its next control');
const next = nextSemutaRicheseCacheKarama(game);
assert.ok(next, `Supply the original owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSemutaRicheseCacheKarama(game, next.actor, next.action, actions);
}
throw Error('The original Semuta/cache programme did not reach its finite boundary');
}
/** Public bare Basic start or the original Advanced Semuta preview. Only
* unopened main/Spice positions and scoped scalar lotteries are selected.
* Semuta is acquired through a real earlier paid cache1 auction. */
export function createSemutaRicheseCacheKaramaFixture(options: {
advanced?: boolean; position?: 'first' | 'last'; blackMarket?: boolean;
} = {}) {
const advanced = options.advanced ?? false, position = options.position ?? 'first';
assert.ok(!options.blackMarket || advanced, 'Only the original Advanced rules offer Black Market');
const owner = 'richese', holder = 'choam', spender = 'atreides', emperor = 'emperor';
let game = createGame('SEMUTARICHESECACHEKARAMA', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
for (const faction of [holder, spender, emperor] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaRicheseCacheKaramaStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaRicheseCacheKarama(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRicheseCacheKarama(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = initializeSemutaGameForAudit(game);
else act(game.host, { type: 'start' });
const setup = structuredClone(game), initialization = { before: lobby, after: setup, advanced };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(player => player.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaRicheseCacheKaramaInventory(game);
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3);
assert.equal(ordinary.length, 3);
const selected = [ordinary[0], ordinary[1], printed, ordinary[2]];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), cost = printed.id;
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.ok(semutaRicheseCacheKaramaPlayer(game, spender).hand.some(card => card.id === cost));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const front: Game['spiceDeck'] = [], spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
for (let position = 0; position < (advanced ? 4 : 2); position++) {
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
const card = game.spiceDeck.splice(from, 1)[0]; front.push(card);
spiceOrder.push({ position, from, card: structuredClone(card) });
}
game.spiceDeck.unshift(...front);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let beforeBid: Game | undefined, bidStep: SemutaRicheseCacheKaramaStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaRicheseCacheKaramaPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaRicheseCacheKarama(game); assert.ok(next);
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
beforeBid = structuredClone(game); bidStep = structuredClone(next); bidAt = actions.length;
}
act(next.actor, next.action);
}
assert.ok(beforeBid && bidStep && bidAt >= 0, 'The holder must buy Semuta through the original cache auction');
const acquired = structuredClone(game), acquisition = {
before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired,
};
assert.equal(semutaRicheseCacheKaramaPlayer(beforeBid, holder).spice - semutaRicheseCacheKaramaPlayer(game, holder).spice, 1);
assert.equal(semutaRicheseCacheKaramaPlayer(game, owner).spice - semutaRicheseCacheKaramaPlayer(beforeBid, owner).spice, 1);
assert.equal(game.richeseCache!.length, 9);
let blackMarketSale: { before: Game; steps: SemutaRicheseCacheKaramaStep[]; after: Game; card: string } | null = null;
if (options.blackMarket) {
reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening &&
state.decision?.kind === 'richeseBlackMarket');
const before = structuredClone(game), start = actions.length;
const offered = semutaRicheseCacheKaramaPlayer(game, owner).hand.find(card => card.kind === 'projectile'); assert.ok(offered);
act(owner, { type: 'decision', event: game.richeseBidding!.event, card: offered.id,
method: 'onceAround', direction: 'clockwise' });
for (let count = 0; game.decision?.kind !== 'richeseDeclaration' && count < 40; count++) {
const lot = game.richeseAuction;
const next = semutaRicheseCacheKaramaClean(game) && lot?.source === 'blackMarket' &&
!lot.outcome && lot.active === emperor && lot.bid === 0
? { actor: emperor, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } as Action }
: nextSemutaRicheseCacheKarama(game);
assert.ok(next); act(next.actor, next.action);
}
assert.equal(game.decision?.kind, 'richeseDeclaration');
assert.ok(semutaRicheseCacheKaramaPlayer(game, emperor).hand.some(card => card.id === offered.id));
assert.equal(semutaRicheseCacheKaramaPlayer(before, emperor).spice - semutaRicheseCacheKaramaPlayer(game, emperor).spice, 1);
assert.equal(semutaRicheseCacheKaramaPlayer(game, owner).spice - semutaRicheseCacheKaramaPlayer(before, owner).spice, 1);
blackMarketSale = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game), card: offered.id };
}
reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening &&
state.decision?.kind === 'richeseDeclaration');
const beforeDeclaration = structuredClone(game), declarationStep: SemutaRicheseCacheKaramaStep = {
actor: owner, action: { type: 'decision', event: game.richeseBidding!.event, position },
};
act(declarationStep.actor, declarationStep.action);
assert.equal(game.response?.kind, 'richeseAuction'); assert.equal(game.response?.owner, owner);
assert.equal(game.richeseBidding!.position, position);
assert.equal(game.richeseBidding!.cacheCanceled, false);
assert.equal(game.richeseBidding!.blackMarketSold, !!blackMarketSale);
assert.equal(game.richeseBidding!.normalCountBeforeCache, 4 - Number(!!blackMarketSale));
assert.equal(game.richeseBidding!.normalCount, 3 - Number(!!blackMarketSale));
assert.ok(semutaRicheseCacheKaramaPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaRicheseCacheKaramaPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaRicheseCacheKaramaInventory(game), originalInventory);
const source = structuredClone(game), cache = game.richeseCache!.map(card => card.id);
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
return {
game, source, gameBeforeCost: source, action, owner, richese: owner, holder, spender, emperor,
cost, karama: cost, semuta: SEMUTA_DRUG_ID, advanced, position,
event: game.richeseBidding!.event, beforeCount: game.richeseBidding!.normalCountBeforeCache!,
beforeNormalCount: game.richeseBidding!.normalCount!, cache, beforeCache: cache,
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
beforeDeclaration, declaration: { before: beforeDeclaration, step: declarationStep, after: source },
acquired, acquisition, blackMarketSale, originalInventory, inventory: originalInventory, originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
advanced
? 'Original Advanced Semuta preview initializer acts only on the ready undealt Richese/CHOAM/Atreides/Emperor lobby, with original main35/cache10 and no optional modules. It is not a newly admitted public Advanced mode.'
: 'Actual public bare Basic Richese ready/start initializes its existing Semuta preview before original setup; original main35/cache10 and no optional modules.',
'Select four original unopened starting-card positions, including the printed main-deck Karama actually dealt to Atreides. Position only the first two Basic/four Advanced still-unplayed original land Spice Cards and scope scalar lotteries; no played card custody, wallet, force, phase, allowance or response is assigned.',
'CHOAM actually buys the original Semuta in the first native Once Around cache auction for one spice paid once to Richese. Other original bids/pass responses, normal auctions, Revival and combined turns finish through native controls. First-turn Advanced Black Market is actually declined, never marked sold or cleared by assignment.',
blackMarketSale
? 'At the real second-turn Advanced Black Market, Richese sells its original dealt projectile to Emperor for one through actual Once Around bids and native seller credit. That completed sale independently reduces the frozen ordinary count by one.'
: 'The second-turn Advanced Black Market, when present, is also actually declined before declaration.',
`At the real turn2 Bidding declaration, Richese chooses ${position}. Stop at its actual compulsory-cache response before Atreides spends the genuinely dealt printed Karama. The frozen before-cache count is ${4 - Number(!!blackMarketSale)}, the reduced ordinary count ${3 - Number(!!blackMarketSale)} and the original cache stock nine.`,
],
};
}
export type SemutaRicheseCacheKaramaFixture = ReturnType<typeof createSemutaRicheseCacheKaramaFixture>;
export function claimSemutaRicheseCacheKarama(game: Game, fixture: Pick<SemutaRicheseCacheKaramaFixture, 'holder' | 'cost'>,
actions: SemutaRicheseCacheKaramaStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.reaction?.stage === 'offer');
const event = pending.batch.event;
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [fixture.cost]);
game = actSemutaRicheseCacheKarama(game, fixture.holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.batch.event === event && game.pendingTreacheryDiscard.reaction?.stage === 'select')
game = actSemutaRicheseCacheKarama(game, fixture.holder, { type: 'semutaSelect', event, card: fixture.cost }, actions);
return game;
}
export function passSemutaRicheseCacheKarama(game: Game, actions: SemutaRicheseCacheKaramaStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.reaction?.stage === 'offer');
const event = pending.batch.event;
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length + 1; count++) {
const reaction = game.pendingTreacheryDiscard.reaction; assert.ok(reaction?.stage === 'offer');
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
game = actSemutaRicheseCacheKarama(game, player.id, { type: 'semutaPass', event }, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
return game;
}
export function finishSemutaRicheseCacheKarama(game: Game, actions: SemutaRicheseCacheKaramaStep[] = []): Game {
return advanceSemutaRicheseCacheKarama(game, state => state.phase === 3 &&
state.richeseBidding?.stage === 'normal' && !!state.auction && semutaRicheseCacheKaramaClean(state), actions);
}
+207
View File
@@ -0,0 +1,207 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actTruthMovement } from './fixture-truth-movement';
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { richeseNormalBetrayalInventory } from './fixture-richese-normal-betrayal';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type SemutaRicheseGiftKaramaStep = { actor: string; action: Action };
export const actSemutaRicheseGiftKarama = actTruthMovement;
export const semutaRicheseGiftKaramaInventory = richeseNormalBetrayalInventory;
export const semutaRicheseGiftKaramaPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
export const semutaRicheseGiftKaramaClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRicheseGift;
const GIFT = 'richese-distrans', DONOR = 'richese';
/** Original bidding and readiness controls. Fresh disposal, the gift itself
* and the selected Karama cancellation remain explicit caller actions. */
export function nextSemutaRicheseGiftKaramaStep(game: Game, fullHand = false): SemutaRicheseGiftKaramaStep | null {
if (game.pendingTreacheryDiscard || game.pendingRicheseGift || game.truthtrance) return null;
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'richeseCache') {
const selected = game.turn === 1 ? SEMUTA_DRUG_ID : GIFT;
const card = game.richeseCache!.find(card => card.id === selected);
assert.ok(card, `Original gift source needs ${selected}: ${JSON.stringify({ turn: game.turn, phase: game.phase,
decision: d.kind, cache: game.richeseCache!.map(card => card.id),
holders: game.players.map(p => ({ id: p.id, cards: p.hand.map(card => card.id) })) })}`);
return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: card.id, method: 'silent' } };
}
if (d?.kind === 'auctionPayment') return { actor: d.player, action: { type: 'decision', karama: false } };
if (!d && game.status === 'playing' && game.phase === 5)
return { actor: game.active!, action: { type: 'endMovement' } };
}
if (semutaRicheseGiftKaramaClean(game)) {
const lot = game.richeseAuction;
if (lot && !lot.outcome) {
// Once Around closes unbid before its owner acts after every rival passes.
// Silent gives Richese an actual paid1 self-purchase while all rivals
// submit their original zero bids, without a fictitious prior rival bid.
assert.equal(lot.method, 'silent');
const actor = lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)); assert.ok(actor);
return { actor, action: { type: 'richeseBid', event: lot.event, amount: actor === DONOR ? 1 : 0, allyPayment: 0 } };
}
if (fullHand && game.turn === 1 && game.auction?.active === DONOR && game.auction.bid === 0 &&
semutaRicheseGiftKaramaPlayer(game, DONOR).hand.length < 3)
return { actor: DONOR, action: { type: 'bid', amount: 1, allyPayment: 0 } };
}
return nextTruthMovementNativeEarlyStep(game, {
position: 'first', method: 'silent', keepUnbid: false,
card: game.richeseCache?.find(card => card.id === (game.turn === 1 ? SEMUTA_DRUG_ID : GIFT))?.id,
});
}
export function advanceSemutaRicheseGiftKarama(game: Game, until: (state: Game) => boolean,
actions: SemutaRicheseGiftKaramaStep[] = [], fullHand = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaRicheseGiftKaramaStep(game, fullHand);
assert.ok(step, 'Resolve the actual fresh discard or gift before continuing');
game = actSemutaRicheseGiftKarama(game, step.actor, step.action, actions);
}
throw Error('The original Semuta/Richese programme did not reach its finite boundary');
}
/** Pass only this current fresh batch; preserve any restored response parent. */
export function passSemutaRicheseGiftKarama(game: Game, actions: SemutaRicheseGiftKaramaStep[] = []): Game {
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
game = actSemutaRicheseGiftKarama(game, actor.id, { type: 'semutaPass', event }, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
}
export function claimSemutaRicheseGiftKarama(game: Game, holder: string,
actions: SemutaRicheseGiftKaramaStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
game = actSemutaRicheseGiftKarama(game, holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaRicheseGiftKarama(game, holder, { type: 'semutaSelect', event, card }, actions);
return game;
}
/** The used-cost continuation completes the prevention itself. Do not allow
* the gift or spend a restored parent response in this helper. */
export function finishSemutaRicheseGiftKarama(game: Game): Game {
assert.ok(!game.pendingTreacheryDiscard, 'Choose the original fresh-discard reaction first');
assert.ok(!game.pendingRicheseGift, 'The original gift prevention must finish before its parent resumes');
return game;
}
/** Genuine native acquisitions/alliance. The only controls over physical stock
* are unopened starting-card/main-deck/Spice positions and original lotteries.
* No played hand, balance, army, alliance, phase or paused receipt is assigned. */
export function createSemutaRicheseGiftKaramaFixture(options: {
advanced?: boolean; parent?: 'quiet' | 'emperorIncome'; fullHand?: boolean;
} = {}) {
const advanced = options.advanced ?? false, parent = options.parent ?? 'quiet', fullHand = options.fullHand ?? false;
const donor = DONOR, holder = donor, recipient = 'emperor', spender = 'atreides', observer = 'choam';
let game = createGame('SEMUTARICHESEGIFTKARAMA', newPlayer(donor, 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['choam', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: SemutaRicheseGiftKaramaStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaRicheseGiftKarama(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRicheseGiftKarama(game, until, actions, fullHand); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => !p.hand.length));
const originalInventory = semutaRicheseGiftKaramaInventory(game); assert.equal(originalInventory.length, 45);
const karamas = game.deck.filter(card => card.effect === 'karama'); assert.equal(karamas.length, 2);
const ordinary = game.deck.find(card => card.kind === 'projectile'), shield = game.deck.find(card => card.kind === 'shield');
assert.ok(ordinary && shield);
const deal = [ordinary.id, karamas[0].id, karamas[1].id, shield.id];
const dealOrder = deal.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card };
});
const beforeStartingDeal = structuredClone(game), cost = karamas[1].id, parentCounter = karamas[0].id;
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), deal);
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const order = (worm: boolean, position: number) => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper
: 'territory' in card && !card.discovery)); assert.ok(from >= position);
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
};
order(false, 0); if (advanced) order(false, 1);
reach(state => state.turn === 1 && state.phase === 2 && semutaRicheseGiftKaramaClean(state));
let normalOrder: { card: string; from: number; before: Game; after: Game } | null = null;
if (fullHand) {
const before = structuredClone(game), from = game.deck.findIndex(card => card.kind === 'shield'); assert.ok(from >= 0);
const card = game.deck.splice(from, 1)[0]; game.deck.unshift(card);
normalOrder = { card: card.id, from, before, after: structuredClone(game) };
}
const buy = (card: string) => {
const before = structuredClone(game), start = actions.length;
reach(state => semutaRicheseGiftKaramaPlayer(state, donor).hand.some(candidate => candidate.id === card));
assert.ok(actions.slice(start).some(step => step.actor === donor && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(semutaRicheseGiftKaramaPlayer(before, donor).spice - semutaRicheseGiftKaramaPlayer(game, donor).spice, 1);
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game), card, price: 1 };
};
const semutaPurchase = buy(SEMUTA_DRUG_ID);
reach(state => state.turn === 1 && state.phase === 4 && semutaRicheseGiftKaramaClean(state));
const afterFirstBidding = structuredClone(game);
assert.equal(semutaRicheseGiftKaramaPlayer(game, donor).hand.length, fullHand ? 3 : 2);
if (normalOrder) assert.ok(semutaRicheseGiftKaramaPlayer(game, donor).hand.some(card => card.id === normalOrder.card));
reach(state => state.turn === 2 && state.phase === 1 && semutaRicheseGiftKaramaClean(state) &&
!state.nexus && !state.spiceWindow && !state.spiceResolution && !state.spiceSequence && !state.ready.length);
const beforeWorm = structuredClone(game); order(true, 0); order(false, 1); if (advanced) order(false, 2);
reach(state => state.nexus && semutaRicheseGiftKaramaClean(state) && !state.spiceWindow && !state.spiceResolution);
const alliance = transition(() => {
act(donor, { type: 'alliance', target: recipient }); act(recipient, { type: 'alliance', target: donor });
});
assert.equal(semutaRicheseGiftKaramaPlayer(game, donor).ally, recipient);
assert.equal(semutaRicheseGiftKaramaPlayer(game, recipient).ally, donor);
reach(state => state.turn === 2 && state.phase === 2 && semutaRicheseGiftKaramaClean(state));
const giftPurchase = buy(GIFT);
assert.equal(game.response?.kind, 'emperorIncome');
assert.equal(game.currentAuctionSale?.amount ?? game.auction?.bid, 1);
if (parent === 'quiet') reach(semutaRicheseGiftKaramaClean);
assert.equal(semutaRicheseGiftKaramaPlayer(game, holder).hand.length, fullHand ? 4 : 3);
assert.ok(semutaRicheseGiftKaramaPlayer(game, spender).hand.some(card => card.id === cost));
assert.ok(semutaRicheseGiftKaramaPlayer(game, observer).hand.some(card => card.id === parentCounter));
assert.ok(viewGame(game, donor).richeseGift?.cards.some(card => card.id === GIFT));
const beforeGift = structuredClone(game), originalParent = { response: structuredClone(game.response),
decision: structuredClone(game.decision), phaseOpening: structuredClone(game.phaseOpening), pendingKarama: structuredClone(game.pendingKarama) };
const giftAction: Action = { type: 'richeseGift', card: GIFT }, giftDeclaration = transition(() => act(donor, giftAction));
assert.equal(game.response?.kind, 'richeseGift'); assert.equal(game.response?.recipient, recipient);
assert.equal(game.pendingRicheseGift?.intent.cardId, GIFT);
assert.deepEqual(game.pendingRicheseGift?.resume, originalParent);
assert.deepEqual(semutaRicheseGiftKaramaInventory(game), originalInventory);
const action: Action = { type: 'card', card: cost, mode: 'cancel' }, source = structuredClone(game);
return { game, source, beforeCost: source, beforeGift, advanced, parent, fullHand,
holder, donor, owner: donor, recipient, spender, observer, parentCounter,
semuta: SEMUTA_DRUG_ID, cost, karama: cost, gift: GIFT, card: cost, action, giftAction,
lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
semutaPurchase, normalOrder, afterFirstBidding, beforeWorm, alliance, giftPurchase, giftDeclaration, originalParent,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Basic uses the real public bare Richese/CHOAM/Atreides/Emperor start. Advanced uses the existing explicit Semuta initializer on the original ready undealt lobby. Both retain original main35/cache10; no mode is converted after start.',
'Only unopened original starting-card positions (one actual printed Karama each to Atreides/CHOAM), original land/Worm Spice positions and scalar lotteries are selected. Each actual Silent cache auction has a real Richese bid1 and original zero submissions from all other eligible seats: Richese pays1 for Semuta on turn1, forms a mutual Emperor alliance at the actual turn2 Worm, and separately pays1 for Distrans before declaring its original allied gift. This avoids native Once Around’s all-rivals-pass free-retention boundary.',
fullHand ? 'The full4 branch selects one still-unopened normal Shield before turn1 Bidding and really bids/pays normal1 for it. Starting ordinary + actual Semuta + actual Shield + actual Distrans fill the native hand before the fresh cost; Semuta exchange frees its own slot.' : 'The ordinary source retains exactly its original starting card plus the two actual paid cache purchases.',
parent === 'emperorIncome' ? 'The original paid-cache EmperorIncome1 remains pending when Richese declares the gift. The distinct originally dealt CHOAM Karama keeps that native parent cancellable after Atreides spends its own original printed Karama.' : 'The quiet branch actually passes the prior original cache-purchase income response before declaring the gift.',
'Stop before the actual Atreides cancellation. No played card custody, wallet, force, alliance, clock, response, receipt or prevention outcome is installed; caller owns the printed cost and neutral fresh-discard claim/pass.',
] };
}
export type SemutaRicheseGiftKaramaFixture = ReturnType<typeof createSemutaRicheseGiftKaramaFixture>;
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaRicheseCacheKaramaFixture as fixture,
actSemutaRicheseCacheKarama as act, claimSemutaRicheseCacheKarama as claim,
passSemutaRicheseCacheKarama as pass, finishSemutaRicheseCacheKarama as finish,
semutaRicheseCacheKaramaInventory as inventory, semutaRicheseCacheKaramaPlayer as player,
type SemutaRicheseCacheKaramaFixture,
} from './fixture-semuta-richese-cache-karama';
const resources = (game: Game) => game.players.map(player => ({
id: player.id, spice: player.spice, reserves: player.reserves, tanks: player.tanks,
forces: player.forces, elites: player.elites, shipped: player.shipped, moved: player.moved,
revived: player.revived, freeForcesRevived: player.freeForcesRevived, ally: player.ally,
}));
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function nativeSource(f: SemutaRicheseCacheKaramaFixture) {
assert.equal(f.initial.status, 'lobby');
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
assert.equal(f.setup.advanced, f.advanced);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
assert.equal(player(f.afterSetup, f.spender).hand.find(card => card.id === f.cost)!.effect, 'karama');
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(player(f.acquisition.after, f.holder).hand.some(card => card.id === f.semuta));
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
assert.equal(player(f.acquisition.after, f.owner).spice - player(f.acquisition.before, f.owner).spice, 1);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 3);
assert.equal(f.game.response?.kind, 'richeseAuction'); assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.game.richeseBidding!.position, f.position);
assert.equal(f.game.richeseBidding!.normalCountBeforeCache, f.beforeCount);
assert.equal(f.game.richeseBidding!.normalCount, f.beforeCount - 1);
assert.equal(f.game.richeseBidding!.cacheCanceled, false);
assert.equal(f.game.richeseBidding!.blackMarketSold, !!f.blackMarketSale);
assert.equal(f.cache.length, 9);
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaRicheseCacheKaramaFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRicheseAuctionDiscard');
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:richeseAuction');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
[{ card: f.cost, by: f.spender, public: true }]);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.response, null, 'The original cache response is parked while its cost is fresh');
if (pending.continuation.kind !== 'karamaRicheseAuctionDiscard') throw Error('Use the exact cache cancellation continuation');
assert.equal(pending.continuation.player, f.spender);
assert.equal(pending.continuation.card, f.cost);
assert.equal(pending.continuation.use.kind, 'cancel');
assert.equal(pending.continuation.use.response.kind, 'richeseAuction');
assert.equal(pending.continuation.use.response.owner, f.owner);
assert.deepEqual(pending.continuation.resume.response, f.game.response);
assert.equal(game.richeseBidding!.cacheCanceled, false, 'Prevention waits for the committed cost reaction');
assert.equal(game.richeseBidding!.normalCount, f.beforeNormalCount);
assert.equal(game.richeseBidding!.normalCountBeforeCache, f.beforeCount);
assert.deepEqual(game.richeseCache!.map(card => card.id), f.cache);
assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
return game;
}
function canceled(state: Game, f: SemutaRicheseCacheKaramaFixture, claimed: boolean) {
const game = finish(state);
assert.ok(!game.pendingTreacheryDiscard);
assert.equal(game.turn, f.game.turn); assert.equal(game.phase, 3);
assert.equal(game.richeseBidding!.event, f.event);
assert.equal(game.richeseBidding!.stage, 'normal');
assert.equal(game.richeseBidding!.position, f.position);
assert.equal(game.richeseBidding!.cacheCanceled, true);
assert.equal(game.richeseBidding!.blackMarketSold, !!f.blackMarketSale);
assert.equal(game.richeseBidding!.normalCountBeforeCache, f.beforeCount);
assert.equal(game.richeseBidding!.normalCount, f.beforeCount);
assert.equal(game.richeseAuction, null);
assert.ok(game.auction); assert.equal(game.auction.index, 0);
assert.equal(game.auction.cards.length, f.beforeCount);
assert.equal(game.auction.bid, 0); assert.equal(game.auction.bidder, null);
assert.equal(game.currentAuctionSale, null);
assert.equal(game.response, null); assert.equal(game.decision, null);
assert.deepEqual(game.richeseCache!.map(card => card.id), f.cache);
assert.deepEqual(resources(game), resources(f.game));
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
for (const advanced of [false, true]) for (const position of ['first', 'last'] as const) for (const claimed of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} genuine paid Semuta ${claimed ? 'claims' : 'passes'} the cache-${position} printed Karama before original prevention completes`, () => {
const f = fixture({ advanced, position }); nativeSource(f);
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
const game = canceled(claimed ? claim(pending, f) : pass(pending), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
});
}
for (const claimed of [false, true]) void test(`Advanced actual Black Market reduction survives ${claimed ? 'claimed' : 'passed'} cache-cancel cost`, () => {
const f = fixture({ advanced: true, position: 'last', blackMarket: true }); nativeSource(f);
assert.ok(f.blackMarketSale);
assert.equal(f.beforeCount, 3); assert.equal(f.beforeNormalCount, 2);
assert.ok(player(f.blackMarketSale.after, f.emperor).hand.some(card => card.id === f.blackMarketSale!.card));
assert.equal(player(f.blackMarketSale.before, f.emperor).spice - player(f.blackMarketSale.after, f.emperor).spice, 1);
assert.equal(player(f.blackMarketSale.after, f.owner).spice - player(f.blackMarketSale.before, f.owner).spice, 1);
const pending = open(f), game = canceled(claimed ? claim(pending, f) : pass(pending), f, claimed);
assert.equal(game.auction!.cards.length, 3, 'Cache prevention restores one lot without undoing the real Black Market reduction');
assert.ok(player(game, f.emperor).hand.some(card => card.id === f.blackMarketSale!.card));
});
void test('parked cost and stale source controls cannot repeat the cache cancellation or change its frozen count', () => {
const f = fixture({ position: 'last' }); nativeSource(f);
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
reject(pending, f.spender, f.action);
reject(pending, f.spender, { type: 'passResponse' });
reject(pending, f.owner, f.declaration.step.action);
const game = canceled(claim(pending, f), f, true);
reject(game, f.owner, f.declaration.step.action);
reject(game, f.holder, { type: 'semutaCommit', event });
assert.equal(game.richeseBidding!.cacheCanceled, true);
assert.equal(game.richeseBidding!.stage, 'normal');
assert.equal(game.richeseBidding!.normalCount, f.beforeCount);
// A future real response may independently accept a held Karama; no global
// ban is inferred from retirement of this original cache opportunity.
});
void test('all four minimal policies resolve the actual fresh cache-cancel cost and native ordinary suffix legally', () => {
for (const difficulty of DIFFICULTIES) {
const f = fixture(), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
let game = pending;
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < 32; count++) {
let proposal: { actor: string; action: Action } | undefined;
for (const seat of game.players) {
const view = viewGame(game, seat.id);
view.players.find(player => player.id === seat.id)!.bot = difficulty;
const before = structuredClone(view), action = botActions(view)[0];
assert.deepEqual(view, before);
if (action) { proposal = { actor: seat.id, action }; break; }
}
assert.ok(proposal);
assert.ok(['semutaPass', 'semutaCommit', 'semutaSelect'].includes(proposal.action.type));
game = act(game, proposal.actor, proposal.action);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
const claimed = !player(game, f.holder).hand.some(card => card.id === f.semuta);
canceled(game, f, claimed);
}
});
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaRicheseGiftKaramaFixture as fixture,
actSemutaRicheseGiftKarama as act, passSemutaRicheseGiftKarama as pass,
claimSemutaRicheseGiftKarama as claim, finishSemutaRicheseGiftKarama as finish,
advanceSemutaRicheseGiftKarama as advance, semutaRicheseGiftKaramaClean as clean,
semutaRicheseGiftKaramaInventory as inventory, semutaRicheseGiftKaramaPlayer as player,
type SemutaRicheseGiftKaramaFixture,
} from './fixture-semuta-richese-gift-karama';
const resources = (game: Game) => ({ players: game.players.map(p => ({ id: p.id, spice: p.spice,
reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites, moved: p.moved, shipped: p.shipped,
ally: p.ally, revived: p.revived })), order: game.order, active: game.active, ready: game.ready,
turn: game.turn, phase: game.phase, movementRemaining: game.movementRemaining });
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaRicheseGiftKaramaFixture) {
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(f.afterSetup, f.observer).hand.some(card => card.id === f.parentCounter));
assert.notEqual(f.cost, f.parentCounter);
for (const acquisition of [f.semutaPurchase, f.giftPurchase]) {
assert.equal(acquisition.price, 1);
const bids = acquisition.steps.filter(step => step.action.type === 'richeseBid');
assert.equal(bids.length, f.game.players.length);
assert.equal(bids.filter(step => step.actor === f.donor && step.action.amount === 1).length, 1);
assert.ok(bids.filter(step => step.actor !== f.donor).every(step => step.action.amount === 0));
assert.equal(player(acquisition.before, f.donor).spice - player(acquisition.after, f.donor).spice, 1);
assert.ok(player(acquisition.after, f.donor).hand.some(card => card.id === acquisition.card));
}
assert.equal(f.alliance.before.nexus, true);
assert.deepEqual(f.alliance.steps, [
{ actor: f.donor, action: { type: 'alliance', target: f.recipient } },
{ actor: f.recipient, action: { type: 'alliance', target: f.donor } },
]);
assert.equal(player(f.game, f.donor).ally, f.recipient); assert.equal(player(f.game, f.recipient).ally, f.donor);
assert.equal(f.game.response?.kind, 'richeseGift'); assert.equal(f.game.response?.recipient, f.recipient);
assert.equal(f.game.pendingRicheseGift?.intent.cardId, f.gift);
assert.deepEqual(f.game.pendingRicheseGift?.resume, f.originalParent);
assert.equal(f.game.discard.some(card => card.id === f.cost), false);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.gift));
assert.equal(player(f.game, f.recipient).hand.some(card => card.id === f.gift), false);
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaRicheseGiftKaramaFixture) {
const offered = act(structuredClone(f.game), f.spender, f.action), pending = offered.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRicheseGiftDiscard');
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
assert.equal(offered.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(player(offered, f.spender).hand.some(card => card.id === f.cost), false);
assert.deepEqual(resources(offered), resources(f.game)); assert.deepEqual(inventory(offered), f.originalInventory);
assert.ok(offered.pendingRicheseGift, 'Original prevention waits for the used physical cost reaction');
assert.deepEqual(offered.pendingRicheseGift.intent, f.game.pendingRicheseGift!.intent);
assert.deepEqual(offered.pendingRicheseGift.resume, f.originalParent);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(offered.richeseGiftBlocked?.cards.includes(f.gift) ?? false, false);
return offered;
}
function prevented(game: Game, f: SemutaRicheseGiftKaramaFixture, claimed: boolean) {
const done = finish(game);
assert.ok(!done.pendingTreacheryDiscard); assert.ok(!done.pendingRicheseGift);
assert.deepEqual(resources(done), resources(f.game)); assert.deepEqual(inventory(done), f.originalInventory);
assert.equal(player(done, f.donor).hand.filter(card => card.id === f.gift).length, 1);
assert.equal(player(done, f.recipient).hand.some(card => card.id === f.gift), false);
assert.equal(player(done, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(done, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(done.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(done, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(done.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.ok(player(done, f.observer).hand.some(card => card.id === f.parentCounter));
assert.deepEqual(done.richeseGiftBlocked, { turn: f.game.turn, phase: f.game.phase, cards: [f.gift] });
assert.deepEqual(done.response, f.originalParent.response); assert.deepEqual(done.decision, f.originalParent.decision);
assert.deepEqual(done.phaseOpening, f.originalParent.phaseOpening);
assert.deepEqual(done.pendingKarama, f.originalParent.pendingKarama);
reject(done, f.donor, f.giftAction);
return done;
}
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} genuine paid Semuta ${claimed ? 'claims' : 'passes'} the original printed Karama before allied Richese gift prevention completes`, () => {
const f = fixture({ advanced }); nativeSource(f);
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
const done = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
assert.equal(player(done, f.holder).hand.length, 3);
reject(done, f.holder, { type: 'semutaCommit', event });
reject(done, f.spender, f.action);
},
);
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native normal-one purchase fills Richese to four before the Semuta singleton exchange`, () => {
const f = fixture({ advanced, fullHand: true }); nativeSource(f);
const normalOrder = f.normalOrder; assert.ok(normalOrder);
assert.ok(f.actions.some(step => step.actor === f.donor && step.action.type === 'bid' && step.action.amount === 1));
assert.ok(player(f.afterFirstBidding, f.holder).hand.some(card => card.id === normalOrder.card));
assert.equal(player(f.game, f.holder).hand.length, 4);
const done = prevented(claim(open(f), f.holder), f, true);
assert.equal(player(done, f.holder).hand.length, 4);
assert.ok(player(done, f.holder).hand.some(card => card.id === normalOrder.card));
},
);
for (const claimed of [false, true]) void test(
`native pending cache-purchase Emperor income survives gift-cost Semuta ${claimed ? 'claim' : 'pass'} then settles once`, () => {
const f = fixture({ advanced: true, parent: 'emperorIncome' }); nativeSource(f);
assert.equal(f.originalParent.response?.kind, 'emperorIncome');
assert.equal(f.beforeGift.currentAuctionSale?.amount ?? f.beforeGift.auction?.bid, 1);
const offered = open(f), done = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
assert.equal(done.response?.kind, 'emperorIncome');
const before = player(done, f.recipient).spice, remaining = advance(done, clean);
assert.equal(player(remaining, f.recipient).spice, before + 1);
assert.ok(!remaining.pendingRicheseGift); assert.ok(!remaining.pendingTreacheryDiscard);
assert.equal(player(remaining, f.donor).hand.filter(card => card.id === f.gift).length, 1);
assert.equal(player(remaining, f.recipient).hand.some(card => card.id === f.gift), false);
assert.deepEqual(remaining.richeseGiftBlocked, done.richeseGiftBlocked);
assert.deepEqual(inventory(remaining), f.originalInventory);
},
);
void test('all four minimal profiles legally answer the exact original Richese-gift Karama discard in both rule bands', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0]; assert.ok(action, difficulty);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
let done = act(structuredClone(offered), f.holder, action);
const claimed = action.type === 'semutaCommit';
if (done.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(done, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
done = act(done, f.holder, selected);
} else if (done.pendingTreacheryDiscard) done = pass(done);
prevented(done, f, claimed);
}
}
});
void test('the original fresh cost rejects stale, foreign and underlying gift replies without consuming another card', () => {
const f = fixture(), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
reject(offered, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(offered, f.spender, { type: 'semutaCommit', event });
reject(offered, f.spender, f.action); reject(offered, f.donor, f.giftAction);
const done = prevented(pass(offered), f, false);
assert.equal(done.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(done.richeseGiftBlocked!.cards.filter(card => card === f.gift).length, 1);
assert.ok(player(done, f.holder).hand.some(card => card.id === f.semuta));
});