Preserve native BG conversion through Semuta physical costs and counters

This commit is contained in:
Quintine committed 2026-10-16 02:41:42 +11:00
1 parent 8a2df97be8
commit f587374923
13 files changed
+693 -63

No files matched your search

+13
View File
@@ -2,6 +2,19 @@
Checked 2026-09-07 for classic GF9 Dune and CHOAM & Richese; preview policy and source-aware Nexus cost boundary updated 30 September 2026. This supplements [CHOAM_KULL_DESIGN.md](CHOAM_KULL_DESIGN.md), [native CHOAM Cunning](NEXUS_CHOAM_RULES.md) and the Kull section of [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md). The source audit itself did not change runtime behavior; later opt-in development implementations follow the explicit selections below. This update does not claim a newly fetched publisher ruling.
## Native fixture integration maintenance — 16 October 2026
The BG Semuta-cost integration selection exposed three stale Nexus Kull
battle fixtures. Native inspection/Sardaukar and promised-fuel preflight now
finish the actual pre-leader/borrowed Voice queue using the existing native
driver. The promise case uses real Atreides prescience rather than clearing
preparation and injecting a response. Three focused cases, the repaired
238-case related selection and two controlled native-parent runtime probes
pass; this is fixture maintenance, not new Kull source/timing authority.
[Current scoped evidence](SEMUTA_SOURCE_UPDATE_20260907.md#native-bg-worthless-and-conversion-counter-costs--16-october-2026).
The selected pre-conversion/pre-discard interception below is unchanged.
## User-selected preview timing — 30 September 2026
The user selected **Different Karama**: reserve the interrupted
+22
View File
@@ -29,6 +29,28 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Semuta preserves native BG conversion and the separate counter cost — 16 October.**
Physical Worthless recovery now precedes the same native conversion counter,
not its effect. The card stays Worthless in its new faction. A later printed
counter is its own fresh cost; recovery still applies the same denial and
restores an older power without resetting passes. Allowed converted prices
retain claimed-cost provenance through real subsequent CHOAM rediscard/stop.
Affected nineteen-file238/238 passes, including eight new cases.
Twenty-six original-action runtime/policy paths and one controlled
acquisition/phase, actual-rediscard refund probe pass. Four actual390px
original journeys cover bank3, conversion denial, original full4 repricing
and the unchanged free normal-auction lot. No overflow/browser/application
errors; actual advisors/next-lot controls finish. Three stale Nexus Kull
fixtures now follow mandatory native preparation/borrowed Voice; three
focused cases and two controlled parent runtime probes pass without
weakening production checks or changing Kull priority.
No worker/D1/real room, human server, deployment or gate changed.
[Canonical stages and exact scope](SEMUTA_SOURCE_UPDATE_20260907.md#native-bg-worthless-and-conversion-counter-costs--16-october-2026).
Final types/lint and production build pass with existing warnings. The
eight new cases pass again after a fixture-only repair; current source
links and all five still-partial Semuta evidence facets resolve.
**Semuta preserves native special effects and separate CHOAM cashout — 16 October.**
Printed special costs now pause at their native disposal point, then resume
one original effect with the committed card even after recovery. Emperor's
+60
View File
@@ -1,5 +1,65 @@
# Dune implementation status
## 16 October 2026 — Semuta retains native BG role, conversion and counter costs
Added `bgKaramaCostDiscard` for the actual Worthless disposal before native
conversion, using one native emitter for direct and committed-cost paths.
Claim/decline opens the same original `worthlessKarama` response; it never
manufactures printed identity or transfers BG's native role. All four uses
retain their original shipment recipient, canceled power, normal lot/winner
and incoming reservation.
Added distinct `karamaBgConversionCancelDiscard` for a later printed counter.
Its saved controls retain the BG pending use; exact fresh counter recovery
finishes that denial once, never the older cost or a new power. Existing
passed history remains: restored native responses may finish automatically,
not reopen for a newly acquired card that already passed the original window.
Claimed-cost provenance now passes through an allowed converted price into
the existing nonduplication refund boundary. One controlled acquisition/
phase source uses actual BG conversion, Semuta, actual CHOAM cashout and
Guild stop; the real rediscarded Worthless remains singly discarded instead
of being returned to its former owner. Armies/wallets are not staged there.
Ordinary post-cost conversion opportunity checks remain independent of
allowed private hand/resource interruptions.
Affected nineteen-file **238/238** passes, including eight new behavior
cases. Twenty-six original-action runtime/policy paths cover four uses,
claim/decline, native allowance/denial, typed Caladan and all four profiles
for both fresh-cost families. Actual cache/normal auctions buy Semuta,
printed Karama and Baliset in original five-faction setup; no played hand,
army, wallet, phase or response is staged in those original programmes.
The integration selection exposed three stale Nexus Kull battle fixtures.
They now finish actual native pre-leader/borrowed Voice using the canonical
driver, and promised-fuel preflight uses a real Atreides inspection instead
of deleting preparation/injecting a response. Three focused cases and two
separate controlled native-parent runtime probes pass. Kull's existing
before-conversion/discard priority and production queue checks remain intact.
Four actual styled390×844 original-action GameTable journeys pass:
physical Baliset recovery then native allowance/bank3; first-cost decline
then printed counter recovery with conversion still denied; converted
native cancellation paying full4 for Guild's original two forces; original
normal-auction Chaumurky delivered free once with BG4 spice unchanged.
Actual original advisor and next-lot inspection choices finish. Viewport/
scroll width390 and no browser/application errors.
Only an owned memory-only loopback host started/stopped. No worker, D1,
authenticated saved room, human server, deployed revision or gate was
exercised or changed. Outside-Git source/API/table/scenes remain under
`/tmp/dune-semuta-bg-*20261016*`, with the separate
`/tmp/dune-bg-kull-parent-smoke-20261016.json`. Shrine sources, wider
E3/combinations, final assurance and the full goal remain unfinished.
[Native stages/source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#native-bg-worthless-and-conversion-counter-costs--16-october-2026),
[continuation](SEMUTA_DISCARD_CONTINUATIONS.md#native-bg-cost-and-counter-stages--16-october-2026).
Final `npm run check:quick` and production build pass with existing
module-register, large-chunk and Unknown-route warnings. Eight new cases
pass again after deleting an unreachable fixture-only inspection branch;
the final action-key assertion uses explicit types for type-aware lint.
Twelve current local links and54 Semuta evidence paths resolve; all five
facets stay Partial without final saved/deployed or complete-mode claims.
## 16 October 2026 — Semuta resumes printed native specials and private CHOAM cashout
Separated validated native activation cost from the single native effect
+17
View File
@@ -2,6 +2,23 @@
7 September 2026. This checkpoint fixes existing Bene Gesserit conversion interruptions and extracts prerequisites needed before a future Karama discard pause. It does **not** implement the complete `karamaCostDiscard` producer, all canceled response bindings, or new rules interpretations. Public Advanced and expansion starts remain gated.
## Native Semuta conversion costs — 16 October 2026
The [current native BG cost continuation](SEMUTA_SOURCE_UPDATE_20260907.md#native-bg-worthless-and-conversion-counter-costs--16-october-2026)
now offers physical Worthless disposal before its native conversion counter,
and separately offers a printed counter's cost before the original denial.
The four-use bindings and incoming-card reservations below remain native.
Claiming a cost cannot bypass conversion, redirect a lot or manufacture a
printed Karama role; previously supported later gifts/private interruptions
retain their ordinary opportunity checks.
An allowed converted shipment price retains claimed-cost provenance for the
existing nonduplication refund policy, including after a real subsequent
CHOAM rediscard. Kull still intercepts before conversion/discard. Historical
absence claims below are superseded only for these bounded connected sources,
not complete modes, Shrine sources or final assurance.
## Actual play defects fixed
A BG direct purchase originally left the previous normal-auction bidder in place until conversion succeeded. After spending its Worthless card, BG could accept two Richese gifts to fill its hand; conversion then awarded the original auction card as a fifth card. Incoming transfer validation now reserves the pending purchase's slot even before BG becomes bidder. One gift that leaves room remains legal. The pre-fix reproducer is `/tmp/dune-bg-karama-gift-probe.ts` with its corresponding log.
+1
View File
@@ -676,6 +676,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Purchased Karama typed price | Supplied authorized Advanced PDF physical16 says any player’s Force Shipment may be half normal, paid to bank. Original typed routes use that independent tariff without new route rights or Nexus stacking; the entire approved invoice is bank-only. Completion consumes only the original grant. Special-stop retains the existing provisional refund only for an unclaimed original cost; Semuta recovery preserves the price but ends that refund eligibility, even after rediscard. Immune Junction keeps its separate source choice. | [Connected typed routes](GUILD_HOMEWORLD_TRANSPORT_RULES.md#separate-permission-and-purchased-price--15-october-2026), [claimed-cost composition](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026) |
| Semuta on shipment-price activation | Claim/decline of the exact printed activation cost creates the same original half-normal bank-paid grant once. Physical recovery does not change its beneficiary/creator. Under the selected nonduplication extension to provisional stop refunds, a claimed cost is not refundable even if the new holder rediscarded it; Guild may use that same card for its actual stop without returning it to the former owner. Unclaimed refunds remain. This is product composition, not a publisher worked example. | [Source, policy and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026) |
| Semuta on printed native special costs | Preserve each native disposal point and original intent. Most printed costs precede their effect; native Emperor's completed revival precedes its cost disposal and is not repeated. CHOAM's selected hand cards form a separate private simultaneous batch after one earned credit; one fresh other-player card may be selected after commitment, never the earlier activation. Existing once-use, joint costs, native permission/typed custody and E3 entry gates remain unchanged. This composes the printed Semuta rule with existing native order, not a new publisher timing FAQ. | [Source/stages and original versus controlled proof](SEMUTA_SOURCE_UPDATE_20260907.md#printed-native-special-costs-and-choam-cashout--16-october-2026) |
| Semuta on native BG conversion/counter costs | The played Worthless is fresh before the original native conversion response; recovery keeps its physical role and cannot bypass that counter. A later printed cancellation is a separate fresh cost and still denies the same conversion. Original four-use opportunity/incoming reservations and older passed history remain. Allowed converted prices retain claimed-cost provenance after real rediscard under the existing nonduplication policy. Kull remains before conversion/discard; Shrine/E3 scope stays separate. | [Native role/source and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#native-bg-worthless-and-conversion-counter-costs--16-october-2026) |
| Purchased price with immune Junction | Keep the original offered half/full tariff and normal payees; no quarter price or bank-only overlay. Under the standing choose-recommendation instruction, accepting this source with a purchased grant requires explicit acknowledgment that it ends unused, with no card refund. Consume only upon actual delivery, not failed, stale, unfunded or deferred acceptance; the ordinary purchased route remains available. This is a conservative product composition, not a publisher worked example. | [Source immunity and selected choice](JUNCTION_TRANSPORT_RULES.md#purchased-price-and-immune-offer--15-october-2026), [original versus controlled proof](JUNCTION_TRANSPORT_RUNTIME.md#purchased-price-source-choice--15-october-2026) |
| Deferred shipment arrival | Quote unsupported ordinary arrivals before saving Guild interception. A validated legacy ordinary declaration may return unspent on the typed arrival error; corrupted declarations and special parent/card receipts are not silently cleared. This repairs continuation without deciding simultaneous reaction priority. | [Reproduction and bounded recovery](DEFERRED_SHIPMENT_ARRIVAL.md) |
| Native Ambassador own-arrival continuation | Under the standing choose-recommended-option instruction, reuse committed entry after its own Guild income, BG Intrusion and accompaniment controls. Existing callbacks put a legitimate accompanying BG fighter child before the deferred original entrant; each sees the then-live token, so declining the child preserves a later original offer and consuming it does not recreate a token. Advisors remain exempt. This child-first/live-token composition is provisional, not a located publisher priority ruling. Actual busy controls, unsupported paired/module guards and separate native rate-window admission remain distinct. | [Native original-action evidence and boundary](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026) |
+27
View File
@@ -2,6 +2,33 @@
Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_AUDIT.md`, then inspected every current `discard(...)` call, direct Treachery-discard mutation and `draw(g)` caller in `game/engine.ts`, plus the Truthtrance discard callback and pure Box/Distrans results. Names below are integration anchors; approximate current lines are navigation aids only. This report adopts no additional source ruling and changes no project file.
## Native BG cost and counter stages — 16 October 2026
The actual physical Worthless creates `bgKaramaCostDiscard` before the
ordinary native conversion response. Its original `KaramaUse`, control
resume and opportunity stay bound. After claim/decline, the single native
conversion emitter opens the same counter: physical recovery is not
conversion allowance and does not change the card's printed role.
Printed cancellation of that conversion creates the separate
`karamaBgConversionCancelDiscard`, retaining the BG pending use in its
saved controls. Its exact fresh Karama, never the older Worthless, may be
recovered. Finish native denial once and restore any older power without
resetting already-passed seats or forcing an otherwise automatic wait.
Claimed-cost provenance passes through an allowed converted price to the
existing grant refund boundary. Genuine subsequent CHOAM disposal cannot
make that old cost refundable again. Ordinary opportunity stamps still
permit previously supported hand/resource/private interruptions after the
fresh event; no stale Game is restored.
[Current source and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#native-bg-worthless-and-conversion-counter-costs--16-october-2026)
records238 affected cases,26 original paths, one explicitly controlled
actual-rediscard probe and four original mobile journeys. The separate
Nexus Kull fixture repair follows existing pre-leader/Voice controls;
Kull's pre-discard priority and all public-start gates remain unchanged.
## Printed special activation and cashout stages — 16 October 2026
`specialKaramaDiscard` retains the validated original native intent, cost,
+65
View File
@@ -2,6 +2,71 @@
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
## Native BG Worthless and conversion-counter costs — 16 October 2026
`bgKaramaCostDiscard` offers the actual played **Worthless** before the native
BG conversion response opens. Claim/pass restores the same original use and
opens its ordinary `worthlessKarama` counter; recovery neither creates a
printed Karama nor grants BG's role to the new holder. Price, native
cancellation, normal-auction purchase and winning payment retain their
original recipient, response or lot and incoming reservation.
A later printed Karama canceling that conversion is a **different fresh
cost**, `karamaBgConversionCancelDiscard`. Its saved controls retain the
original BG pending use. Claim/pass applies that same denial once; the
earlier Worthless is not another candidate. Denying a BG conversion that
targeted an older power restores that power and its passed history, not a new response. Native
normalization may finish that restored power automatically; its real outcome
and payment, not a forced visible pause, are authoritative.
An allowed converted price carries its already-selected claimed-cost fact
from `pendingKarama` into the original price grant. The existing no-duplicate
refund policy therefore survives a later real CHOAM rediscard and Guild
stop; unclaimed original refunds remain unchanged. Once the cost event
retires, the ordinary BG opportunity stamp still excludes legal hand,
balance, pass and seat-control changes as before. This does not freeze the
whole native conversion or bypass its counter.
Affected nineteen-file **238/238** passes, including eight new behavior cases.
Twenty-six original-action runtime/policy paths cover all four uses, both
cost choices, native allow/cancel, typed Caladan sources and all four profiles
for each new fresh-cost family. Three real paid auctions supply Semuta,
printed Karama and BG's Baliset. One **controlled acquisition/phase** probe
then uses actual BG conversion, actual Semuta recovery, real CHOAM cashout
and native Guild stop to prove rediscard cannot restore refund eligibility.
No wallet/force staging or synthetic discard is used in that probe.
Four actual styled390×844 original-action GameTable journeys pass: recovered
Baliset remains Worthless while an allowed price pays bank3; cost decline
then printed-counter recovery still denies conversion; allowed converted
native cancellation pays Guild's original full4 for two real forces; and
normal-auction allowance awards original Chaumurky without charging BG.
Original advisor/next-lot inspection controls finish through actual actions.
Viewport/scroll width390 and no browser/application errors.
The wider batch found three stale Nexus Kull fixtures that skipped mandatory
native pre-leader/borrowed Voice. They now follow the canonical legal queue;
the promised-fuel case uses actual Atreides prescience instead of clearing
preparation/staging a response. Three focused cases and two separate
controlled native-parent runtime probes pass. **Kull remains before BG
conversion/discard** under the prior user policy; no production queue check
or timing rule was weakened.
Only owned memory-only loopback hosts ran. No worker, D1, authenticated saved
room, human server, deployment or mode gate changed. Proof/scenes remain
outside Git under `/tmp/dune-semuta-bg-*20261016*`, with the separate native
fixture proof `/tmp/dune-bg-kull-parent-smoke-20261016.json`.
Shrine-source costs, wider E3/combinations and final assurance remain separate.
[Conversion stages](SEMUTA_DISCARD_CONTINUATIONS.md#native-bg-cost-and-counter-stages--16-october-2026),
[native opportunity contract](KARAMA_PREFLIGHT_AND_CONVERSIONS.md).
Final types/lint and production build pass with existing module-register,
large-chunk and Unknown-route warnings. The eight new behavior cases pass
again after removing an unreachable fixture-only inspection branch; final
type-aware lint also requires explicit typing of its action-key assertion.
Twelve current local links and54 Semuta evidence paths resolve; all five
facets remain Partial, without recovery/deployed or complete-mode assurance.
## Printed native special costs and CHOAM cashout — 16 October 2026
`specialKaramaDiscard` now offers a printed special-Karama activation cost
+138 -43
View File
@@ -1394,13 +1394,21 @@ export type Game = {
stateSignature: string;
}
| {
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard';
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard';
player: string;
card: string;
use: Extract<KaramaUse, { kind: 'cancel' }>;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'bgKaramaCostDiscard';
player: string;
card: string;
use: KaramaUse;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'karamaShipmentPriceDiscard';
player: string;
@@ -2085,6 +2093,8 @@ export type Game = {
pendingKarama?: {
owner: string;
use: KaramaUse;
/** Physical recovery does not undo conversion, but ends a later price refund. */
costClaimed?: true;
/** New conversions bind their original power, shipment or lot. Absent in legacy saves. */
opportunity?: {
kind: 'cancel' | 'shipment' | 'auction';
@@ -5469,9 +5479,35 @@ function semutaGuildShipmentKaramaResponse(g: Game, response: ResponseWindow): b
else validateGuildRateResponse(g, response);
return true;
}
function semutaBgConversionCancelResponse(
g: Game, response: ResponseWindow, pending: Game['pendingKarama'],
): boolean {
return response.kind === 'worthlessKarama' && g.advanced && !!pending &&
response.owner === pending.owner && getPlayer(g, pending.owner).faction === 'beneGesserit' &&
(pending.opportunity === undefined ||
pending.opportunity.signature === karamaOpportunitySignature(g, pending));
}
type BgKaramaCostDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'bgKaramaCostDiscard' }
>;
function bgKaramaCostDiscardSignature(g: Game, c: BgKaramaCostDiscardContinuation) {
return JSON.stringify({
player: c.player, card: c.card, use: c.use,
parent: nullentropyParentSignature(g, c.resume, `bgKarama:${c.card}`),
opportunity: karamaOpportunitySignature(g, { owner: c.player, use: c.use }),
battle: g.battle, price: g.karamaShipping, homeworlds: g.homeworlds,
rate: g.guildRateBlocked, permission: g.guildTransportBlocked,
players: g.players.map(p => ({
id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id),
spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, shipped: p.shipped, moved: p.moved,
})),
});
}
type KaramaResponseDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' }
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' }
>;
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
const battle = c.kind === 'karamaBattleDiscard';
@@ -5479,6 +5515,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
const emperorShare = c.kind === 'karamaEmperorShareDiscard';
const income = c.kind === 'karamaIncomeDiscard';
const shipment = c.kind === 'karamaShipmentDiscard';
const conversion = c.kind === 'karamaBgConversionCancelDiscard';
return JSON.stringify({
player: c.player, card: c.card, use: c.use,
parent: nullentropyParentSignature(g, c.resume,
@@ -5522,12 +5559,17 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
purchased: g.karamaShipping, cunning: g.nexusGuildCunningHistory?.at(-1),
active: g.active, ready: g.ready, movementRemaining: g.movementRemaining,
} : undefined,
conversion: conversion ? {
pending: c.resume.pendingKarama, battle: g.battle, price: g.karamaShipping,
homeworlds: g.homeworlds, rate: g.guildRateBlocked, permission: g.guildTransportBlocked,
auction: g.auction, richeseAuction: g.richeseAuction,
} : undefined,
order: g.order,
players: g.players.map(p => ({
id: p.id, faction: p.faction, ally: p.ally,
hand: p.hand.map(card => card.id), spice: p.spice,
shipped: income || shipment ? p.shipped : undefined,
physical: battle || worthless || income || shipment
shipped: income || shipment || conversion ? p.shipped : undefined,
physical: battle || worthless || income || shipment || conversion
? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, leaders: battle ? p.leaders : undefined,
noField: shipment ? p.noField : undefined,
@@ -6389,6 +6431,19 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === residualPoisonDiscardSignature(g, c),
'The used Residual Poison no longer matches its committed leader death.',
);
} else if (continuation?.kind === 'bgKaramaCostDiscard') {
const c = continuation, entry = batch.entries[0], owner = getPlayer(g, c.player);
requireRule(
g.advanced && owner.faction === 'beneGesserit' &&
!g.response && !g.decision && !g.pendingKarama && !g.phaseOpening &&
batch.cause === 'karama:conversion' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
(c.use.kind !== 'shipment' || c.use.card === c.card) &&
c.stateSignature === bgKaramaCostDiscardSignature(g, c),
'The spent Worthless no longer matches its original native Karama conversion.',
);
validateKaramaUse(g, owner, c.use);
} else if (continuation?.kind === 'karamaCharityDiscard' ||
continuation?.kind === 'karamaInflationDiscard' ||
continuation?.kind === 'karamaBgCharityDiscard' ||
@@ -6396,7 +6451,8 @@ function treacheryDiscardIntegrity(g: Game) {
continuation?.kind === 'karamaChoamWorthlessDiscard' ||
continuation?.kind === 'karamaEmperorShareDiscard' ||
continuation?.kind === 'karamaIncomeDiscard' ||
continuation?.kind === 'karamaShipmentDiscard') {
continuation?.kind === 'karamaShipmentDiscard' ||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
!g.response &&
@@ -6420,11 +6476,14 @@ function treacheryDiscardIntegrity(g: Game) {
(c.kind === 'karamaIncomeDiscard' &&
semutaIncomeKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaShipmentDiscard' &&
semutaGuildShipmentKaramaResponse(g, c.use.response))) &&
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaBgConversionCancelDiscard' &&
semutaBgConversionCancelResponse(g, c.use.response, c.resume.pendingKarama))) &&
c.use.response.owner !== c.player &&
c.resume.response?.kind === c.use.response.kind &&
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) &&
!c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening &&
(c.kind === 'karamaBgConversionCancelDiscard' ||
(!c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening)) &&
batch.cause === `karama:${c.use.response.kind}` &&
batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
@@ -7292,6 +7351,10 @@ function semutaOfferSupported(
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
continuation.kind === 'karamaBgConversionCancelDiscard';
if (continuation.kind === 'karamaBgConversionCancelDiscard' &&
!semutaBgConversionCancelResponse(g, continuation.use.response, continuation.resume.pendingKarama)) return false;
if (continuation.kind !== 'ordinaryCardDiscard' &&
continuation.kind !== 'nullentropyDiscard' &&
continuation.kind !== 'distransDiscard' &&
@@ -7313,6 +7376,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaShipmentDiscard' &&
continuation.kind !== 'karamaShipmentPriceDiscard' &&
continuation.kind !== 'specialKaramaDiscard' &&
!bgDiscard &&
continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard' &&
continuation.kind !== 'karamaPurchaseDiscard' &&
@@ -7342,7 +7406,7 @@ function semutaOfferSupported(
(continuation.promise === null ||
continuation.promise.kind === 'shipment' ||
continuation.promise.kind === 'battle'))) return false;
if (g.homeworlds && continuation.kind !== 'kaitainDiscard' &&
if (g.homeworlds && !bgDiscard && continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
continuation.kind !== 'karamaShipmentDiscard' &&
@@ -7451,6 +7515,7 @@ function semutaOfferSupported(
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
: true) &&
(continuation.kind === 'battleCleanup' ||
bgDiscard ||
continuation.kind === 'specialKaramaDiscard' ||
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' ||
@@ -7480,8 +7545,8 @@ function semutaOfferSupported(
? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening)))) &&
(continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' || !g.karamaShipping) &&
(continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) &&
(bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin &&
(continuation.sale.origin === 'normal'
? !!g.auction && !g.richeseAuction
@@ -7495,11 +7560,12 @@ function semutaOfferSupported(
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
: !g.auction)) &&
(continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
(bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' ||
bgDiscard ||
continuation.kind === 'specialKaramaDiscard' ||
continuation.kind === 'residualPoisonDiscard' ||
continuation.kind === 'truthtranceDiscard' ||
@@ -7525,6 +7591,7 @@ function semutaOfferSupported(
!g.pendingAmbassador && !g.pendingTerrorEntry &&
(continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' ||
bgDiscard ||
(!g.pendingShipment && !g.pendingHomeworldShipment)) &&
!g.pendingRicheseGift &&
((continuation.kind === 'karamaIncomeDiscard' &&
@@ -7752,6 +7819,13 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
const battle = g.battle!;
if (battle.preLeader && !battle.preLeader.closed)
battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player);
} else if (next.kind === 'bgKaramaCostDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
beginWorthlessKaramaConversion(g, getPlayer(g, next.player),
pending.batch.entries[0].card, next.use, !g.discard.some(card => card.id === next.card));
} else if (next.kind === 'specialKaramaDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -7775,6 +7849,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
next.kind === 'karamaEmperorShareDiscard' ||
next.kind === 'karamaIncomeDiscard' ||
next.kind === 'karamaShipmentDiscard' ||
next.kind === 'karamaBgConversionCancelDiscard' ||
next.kind === 'karamaPurchaseDiscard' ||
next.kind === 'karamaPaymentDiscard') {
g.response = next.resume.response;
@@ -14417,6 +14492,8 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
const used = discard(g, p, card.id);
const guildShipmentDiscard = g.semutaPreview && card.effect === 'karama' &&
use.kind === 'cancel' && semutaGuildShipmentKaramaResponse(g, use.response);
const bgConversionCancelDiscard = g.semutaPreview && card.effect === 'karama' &&
use.kind === 'cancel' && semutaBgConversionCancelResponse(g, use.response, g.pendingKarama);
if (g.semutaPreview && card.effect === 'karama' &&
use.kind === 'cancel' &&
(use.response.kind === 'choamCharity' ||
@@ -14424,9 +14501,10 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) ||
semutaChoamKaramaResponse(g, use.response) ||
semutaEmperorShareKaramaResponse(g, use.response) ||
semutaIncomeKaramaResponse(g, use.response) || guildShipmentDiscard)) {
semutaIncomeKaramaResponse(g, use.response) || guildShipmentDiscard || bgConversionCancelDiscard)) {
const continuation: KaramaResponseDiscardContinuation = {
kind: guildShipmentDiscard ? 'karamaShipmentDiscard'
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
: guildShipmentDiscard ? 'karamaShipmentDiscard'
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
: use.response.kind === 'choamInflation'
? 'karamaInflationDiscard'
@@ -14445,6 +14523,9 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
const entries = [{ card: used, discardedBy: p.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
g.response = null;
if (bgConversionCancelDiscard) {
g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
}
continuation.stateSignature = karamaResponseDiscardSignature(g, continuation);
stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation);
return;
@@ -14481,36 +14562,47 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
}
}
if (card.kind === 'worthless') {
requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.');
g.pendingKarama = { owner: p.id, use };
g.pendingKarama.opportunity = {
kind:
use.kind === 'cancel'
? 'cancel'
: use.kind === 'shipment'
? 'shipment'
: 'auction',
signature: karamaOpportunitySignature(g, g.pendingKarama),
};
g.response = {
kind: 'worthlessKarama',
owner: p.id,
passed: [],
intent:
use.kind === 'cancel'
? `Cancel a power used by ${getPlayer(g, use.response.owner).name}.`
: use.kind === 'shipment'
? `Give ${getPlayer(g, use.recipient).name} Guild shipment rates paid to the bank.`
: use.kind === 'purchase'
? 'Take the current auction card without paying spice.'
: 'Pay the winning auction bid without spending spice.',
};
log(
g,
`${p.name} used ${card.name} as Karama; the faction power awaits responses.`,
);
if (g.semutaPreview) {
const continuation: BgKaramaCostDiscardContinuation = {
kind: 'bgKaramaCostDiscard', player: p.id, card: used.id, use,
resume: { response: g.response, decision: g.decision,
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
stateSignature: '',
};
const entries = [{ card: used, discardedBy: p.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
continuation.stateSignature = bgKaramaCostDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'karama:conversion', entries, continuation);
return;
}
}
beginWorthlessKaramaConversion(g, p, card, use);
} else completeKarama(g, p, use, card);
}
function beginWorthlessKaramaConversion(
g: Game, p: Player, card: Card, use: KaramaUse, costClaimed = false,
) {
requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.');
const pending: NonNullable<Game['pendingKarama']> = { owner: p.id, use };
if (costClaimed) pending.costClaimed = true;
g.pendingKarama = pending;
pending.opportunity = {
kind: use.kind === 'cancel' ? 'cancel' : use.kind === 'shipment' ? 'shipment' : 'auction',
signature: karamaOpportunitySignature(g, pending),
};
g.response = {
kind: 'worthlessKarama', owner: p.id, passed: [],
intent: use.kind === 'cancel'
? `Cancel a power used by ${getPlayer(g, use.response.owner).name}.`
: use.kind === 'shipment'
? `Give ${getPlayer(g, use.recipient).name} Guild shipment rates paid to the bank.`
: use.kind === 'purchase'
? 'Take the current auction card without paying spice.'
: 'Pay the winning auction bid without spending spice.',
};
log(g, `${p.name} used ${card.name} as Karama; the faction power awaits responses.`);
}
function completeKarama(g: Game, p: Player, use: KaramaUse, card?: Card) {
validateKaramaUse(g, p, use);
const played = card?.effect === 'truthtrance' ? 'Truthtrance as Karama' : 'Karama';
@@ -25777,8 +25869,11 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
const pending = g.pendingKarama!;
g.pendingKarama = null;
const owner = getPlayer(g, pending.owner);
if (!canceled) completeKarama(g, owner, pending.use);
else if (pending.use.kind === 'cancel') g.response = pending.use.response;
if (!canceled) {
completeKarama(g, owner, pending.use);
if (pending.costClaimed && pending.use.kind === 'shipment')
g.karamaShipping!.costClaimed = true;
} else if (pending.use.kind === 'cancel') g.response = pending.use.response;
else if (pending.use.kind === 'auctionPayment')
recoverAuctionPayment(g, owner);
} else if (response.kind === 'choamSale') {
+9 -7
View File
@@ -1203,7 +1203,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.',
'A printed Karama canceling native Guild transport permission or shipment rate now offers that exact public cost while the original reserve, cross/return or typed Homeworld declaration remains unpaid. Claim or Continue restores and applies that cancellation once. Recovering the cost never restores a denied route, retains the native discount, finances an unfunded group or repeats shipment. Ordinary movement and legal replacements remain; independent price grants stay distinct.',
'A printed Karama buying another active player’s shipment price also offers its exact fresh cost before that same original grant activates. Claim or Continue preserves the original beneficiary, creator and half-normal bank-paid price. Physical recovery ends the provisional stop-refund eligibility for that cost, even if the claimant later discards it again. Guild can spend that very recovered card for an actual special stop without also refunding it to the former owner. Unclaimed original costs retain their older refund; this is selected source composition, not a publisher worked example.',
'Printed native special-Karama costs now offer Semuta at their original disposal point. Recovery or Continue resumes the same committed native effect once even when its physical cost changed hands. Emperor revival already happened before its native disposal and is not repeated. CHOAM has a separate later private batch for its selected cashout cards after one earned credit: commit before inspection and recover one fresh card, never the old activation or one per discarded card. Original once-use, typed sources and refund remain; Advanced E3 entry and converted costs keep their separate gates.',
'Printed native special-Karama costs now offer Semuta at their original disposal point. Recovery or Continue resumes the same committed native effect once even when its physical cost changed hands. Emperor revival already happened before its native disposal and is not repeated. CHOAM has a separate later private batch for its selected cashout cards after one earned credit: commit before inspection and recover one fresh card, never the old activation or one per discarded card. Original once-use, typed sources and refund remain; Advanced E3 entry and Shrine-source costs keep separate gates.',
'A native Advanced BG Worthless-as-Karama cost also offers its actual physical card before the original conversion response. Claim or Continue cannot turn that card into a printed Karama, grant BG’s role to its new faction or bypass its native counter. A later printed counter cost is a different fresh event; taking it still denies the conversion. Original shipment/power/auction/winning-payment bindings and passed history remain. Allowed converted prices retain claimed-cost provenance even after later real CHOAM disposal, without a duplicate stop refund.',
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.',
'Legal completed CHOAM market sales also offer their used card in the Homeworld profile. Worthless cashout keeps its original two-spice credit; a non-Worthless duplicate keeps its three-spice credit and the original same-name witness stays held. The high-Tupile Worthless-sale prohibition still applies, including duplicate Worthless sales. Claim/pass cannot repeat payout, undo the actual reserve shipment or replace the original market close; no new bank-income interpretation is made.',
'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; prior-move/Hajr, advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.',
@@ -1228,31 +1229,31 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
detail:
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner. Printed native special costs now resume one committed native effect through effect/complete/cashout stages. Original Emperor post-revival disposal is preserved; CHOAM selected cards are a later private simultaneous batch after one credit. Existing native once-use, joint ordering and typed parent remain.',
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner. Printed native special costs now resume one committed native effect through effect/complete/cashout stages. Original Emperor post-revival disposal is preserved; CHOAM selected cards are a later private simultaneous batch after one credit. Existing native once-use, joint ordering and typed parent remain. Native BG physical Worthless cost now precedes the single original conversion emitter; the separately printed cancellation cost retains that pending use and still denies it. All four use bindings, incoming reservations and older passes remain. Allowed converted prices carry claimed-cost provenance through actual later rediscard/refund.',
},
{
area: 'Player controls' as const,
status: 'Partial' as const,
detail:
'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Normal Richese starts remain gated. Earlier four original-action390px native cost/continuation journeys exercise full4 reserve claim, denied cross plus ordinary1, typed full2 all-seat decline, and poor2/full4 release then one-force full2. No overflow/browser/application error; one tool-only stale-node race was resolved with fresh evidence, not replay. Earlier four original-action390px price journeys start at actual activation: bank3 allowance while Guild holds the cost, same-card reserve stop, typed bank2 after all-seat decline, and same-card typed stop followed by real ordinary movement. No overflow/browser/application errors; two tool-only stale-node races were resolved without replay. Four current390px journeys cover original reserve/typed Guild prevention claim/decline and controlled CHOAM private Kulon selection plus Emperor post-revival cost claim. Two original/two controlled; actual native movement/public force inspectors, no overflow/browser/application errors.',
'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Normal Richese starts remain gated. Earlier four original-action390px native cost/continuation journeys exercise full4 reserve claim, denied cross plus ordinary1, typed full2 all-seat decline, and poor2/full4 release then one-force full2. No overflow/browser/application error; one tool-only stale-node race was resolved with fresh evidence, not replay. Earlier four original-action390px price journeys start at actual activation: bank3 allowance while Guild holds the cost, same-card reserve stop, typed bank2 after all-seat decline, and same-card typed stop followed by real ordinary movement. No overflow/browser/application errors; two tool-only stale-node races were resolved without replay. Earlier four390px special-cost journeys cover original reserve/typed Guild prevention claim/decline and controlled CHOAM private Kulon selection plus Emperor post-revival cost claim. Two original/two controlled; actual native movement/public force inspectors, no overflow/browser/application errors. Four new original-action390px journeys start from actual Baliset controls: bank3 after native allowance, printed counter recovery with denial intact, original full4 Guild repricing and unchanged free Chaumurky acquisition. Native advisors/next-lot inspection finish, with no overflow/browser/application errors.',
},
{
area: 'AI' as const,
status: 'Partial' as const,
detail:
'All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration. All four existing profiles recover the original Guild stop cost via legal private actions, and the native prevention then executes once. No new preference, tuning or calibration.',
'All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration. All four existing profiles recover the original Guild stop cost via legal private actions, and the native prevention then executes once. No new preference, tuning or calibration. For each native BG cost and printed counter-cost family, all four existing profiles recover only the current fresh card through their private legal action. Native response allowance/denial remains separate; subsequent owner actions exercise the real retained source. No tuning or calibration.',
},
{
area: 'Documentation' as const,
status: 'Partial' as const,
detail:
'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers (including queued questions, supported shipment promises and battle answers before or after plan submission/reveal), four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources. The native Guild followup distinguishes unpaid transport from completed income and keeps existing denial/no-debt interpretations. Both original cards are actually auctioned; two extra active-price probes explicitly disclose controlled second-card acquisition. Printed price-cost activation is connected below; converted and special costs remain separate. The printed price-cost continuation and claimed-cost nonduplication extension are explicit. Unclaimed original refunds remain provisional and unchanged; recovery does not transfer or repeat the price benefit. Converted costs and wider E3 entries remain separate; printed special costs are connected below. Printed special costs and CHOAM cashout stages follow native disposal order without a new publisher timing ruling. Eight original and18 controlled paths plus2 original/2 controlled surfaces are explicit. Advanced E3 entry, converted costs and full assurance remain separate.',
'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers (including queued questions, supported shipment promises and battle answers before or after plan submission/reveal), four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources. The native Guild followup distinguishes unpaid transport from completed income and keeps existing denial/no-debt interpretations. Both original cards are actually auctioned; two extra active-price probes explicitly disclose controlled second-card acquisition. Printed price-cost activation is connected below; converted and special costs remain separate. The printed price-cost continuation and claimed-cost nonduplication extension are explicit. Unclaimed original refunds remain provisional and unchanged; recovery does not transfer or repeat the price benefit. Shrine costs and wider E3 entries remain separate; native BG and printed special costs are connected. Printed special costs and CHOAM cashout stages follow native disposal order without a new publisher timing ruling. Eight original and18 controlled paths plus2 original/2 controlled surfaces are explicit. Advanced E3 entry, Shrine costs and full assurance remain separate. The Worthless remains its actual printed role and does not grant BG conversion to a new faction. Native cost versus printed counter, four-use source/incoming bindings and claimed converted-price refund are explicit. Kull pre-discard priority is unchanged; stale native Kull fixture queues were corrected through real decisions. Shrine/E3 and final assurance stay separate.',
},
{
area: 'Verification' as const,
status: 'Partial' as const,
detail:
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. Earlier eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance. Current sixteen-file224/224 passes with13 new behavior cases. Twenty-six runtime/policy paths pass:8 original-action Guild paths including4 profiles,18 explicitly controlled native-effect paths. Four actual390px journeys pass (2 original/2 controlled). Existing income/refund consumers use actual neutral passes. No worker/D1/saved/deployed or complete-mode claim.',
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. Earlier eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance. Earlier sixteen-file224/224 passes with13 new behavior cases. Twenty-six runtime/policy paths pass:8 original-action Guild paths including4 profiles,18 explicitly controlled native-effect paths. Four actual390px journeys pass (2 original/2 controlled). Existing income/refund consumers use actual neutral passes. No worker/D1/saved/deployed or complete-mode claim. Current nineteen-file238/238 passes with8 new cases. Twenty-six original-action runtime/policy paths and four actual original-action390px journeys pass. One controlled acquisition/phase probe uses a REAL subsequent CHOAM rediscard and Guild stop without force/wallet staging or duplicate refund. Separate2 controlled Kull parent probes and3 repaired focused cases pass. No worker/D1/saved/deployed certification.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
@@ -1307,6 +1308,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-guild-transport.test.ts',
'tests/semuta-shipment-price.test.ts',
'tests/semuta-special-karama.test.ts',
'tests/semuta-bg-karama.test.ts',
],
},
],
@@ -2570,7 +2572,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Native Guild cross/return permission has its own unpaid counter before price. Preventing it keeps a legal different shipment/movement without removing native half price. The conservative provisional scope is that original opportunity, not a separately signed Cunning second opportunity; immune Junction and common world-to-foreign-world permission remain independent. Native rate cancellation bills the same paid units and original approved split. Free counters stay free and own/allied No-Field prices one marker. Supplier whole-fee1 requires a new offer/consent before becoming2.',
'A native Guild holder of Richese Secret Ally still ships up to five real forces at one-counter cost. Canceling its native rate changes sand1 to2, not five physical forces to ten paid units. If full2 is unaffordable, the declared Nexus stays spent without refund or reusable coupon while ordinary shipment remains available; this is the selected affordability composition. A still-held card uses the corrected full price. An independent purchased Karama price stays separate.',
'Use a held Karama to purchase the active player’s Force Shipment at half normal price, rounded up, paid entirely to bank. Original typed World routes retain this independent source without new permission or Nexus stacking. Semuta can recover the printed cost without changing its original price beneficiary or creator; a recovered cost cannot also receive the provisional Guild-stop refund, even after rediscard. Unclaimed original costs keep that existing refund. Completion consumes only the grant. Immune Junction keeps its original tariff/payees; accepting it explicitly ends the separate purchased price unused without refund, only upon delivery.',
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each conversion opens a separate cancellation response; the Worthless card is discarded even if the power is canceled. Normal Karama cards resolve without that conversion response.',
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each opens a separate native cancellation response and stays spent if the power is denied. In the Semuta preview, its physical cost offers recovery before that response; the card remains Worthless for its new faction. A later printed cancellation cost is a separate fresh event whose recovery does not undo denial. Allowed converted prices retain the original owner/beneficiary and claimed-cost refund boundary. Normal printed Karama has no BG conversion response.',
'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.',
'Atreides full-plan inspection, Emperor revival, Fremen’s summoned worm, Harkonnen hand exchange the Guild shipment stop and Tleilaxu revival prevention are implemented as once-per-game uses. Other cancellations and special Karamas are still being implemented.',
'In the opt-in Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase. See Kull Wahad for the explicit preview policy and deferred winning-overbid warning.',
+18 -2
View File
@@ -3,6 +3,7 @@ import { botActions } from '../game/bots';
import { takeKullCard } from './fixture-choam-kull';
import { orderNexusSpice } from './fixture-nexus-cards';
import type { FactionId } from '../game/catalog';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export function createNexusChoamKullFixture(options: {
advanced?: boolean; bg?: boolean; seatIds?: string[]; nexusFace?: 'choam' | 'richese';
@@ -79,6 +80,22 @@ export function createNexusChoamKullFixture(options: {
original: original.id, fuel: fuel.id, counter: counter?.id ?? null };
}
/** Follow the real pre-inspection queue; never clear native preparation by hand. */
export function passNexusKullBorrowedVoice(state: Game): Game {
let game = state;
for (let n = 0; game.battle && n < 30 &&
((game.battle.preLeader && !game.battle.preLeader.closed) ||
game.battle.preparation?.kind === 'nexusVoice'); n++) {
const next = nextStrongholdFactionsNativeStep(game);
if (!next) throw new Error('The original pre-Voice preparation needs a legal native action.');
game = applyAction(game, next.actor, next.action);
}
if ((game.battle?.preLeader && !game.battle.preLeader.closed) ||
game.battle?.preparation?.kind === 'nexusVoice')
throw new Error('The original pre-leader and borrowed Voice queues must actually finish.');
return game;
}
/** A native declaration is produced by actions before its Karama/Kull parent
* is suspended. The final faction roster is fixed before genuine setup. */
export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | 'advisors' | 'guild',
@@ -116,8 +133,7 @@ export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | '
game.active = choice.chooser;
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
for (const id of [f.actor, f.other!]) if (game.battle!.preLeader && !game.battle!.preLeader.closed)
game = applyAction(game, id, { type: 'battlePreparationReady', event: game.battle!.event });
game = passNexusKullBorrowedVoice(game);
if (kind === 'inspection') {
if (game.response) game = allowNativeParent(game);
game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' });
+162
View File
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
export type SemutaBgUse = 'shipment' | 'cancel' | 'purchase' | 'auctionPayment';
export type SemutaBgStep = { actor: string; action: Action };
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard;
/** Original native BG/Richese/classic setup, actual paid Semuta, Karama and
* Worthless auctions. Only original undealt lotteries/deck order are selected;
* no played hand/army/wallet/phase/response is staged. */
export function createOriginalSemutaBg(use: SemutaBgUse = 'shipment', homeworlds = false) {
let game = createGame('SEMUTABGCONVERSION', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
for (const [id, name, faction] of [['b', 'Bene Gesserit', 'beneGesserit'], ['a', 'Atreides', 'atreides'],
['e', 'Emperor', 'emperor'], ['g', 'Guild', 'guild']] as const) joinGame(game, newPlayer(id, name, faction));
if (homeworlds) game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
const actions: SemutaBgStep[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action }); game = applyAction(game, actor, action);
};
const reach = (until: (state: Game) => boolean) => {
for (let n = 0; !until(game) && n < 1800; n++) {
assert.notEqual(game.status, 'finished');
const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action);
}
assert.ok(until(game), `Original BG source stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`);
};
reach(state => state.status === 'playing');
const setup = structuredClone(game);
const ki = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(ki >= 0);
const karama = game.deck.splice(ki, 1)[0];
const wi = game.deck.findIndex(card => card.kind === 'worthless'); assert.ok(wi >= 0);
const worthless = game.deck.splice(wi, 1)[0]; game.deck.unshift(karama, worthless);
for (const position of [0, 1]) {
const at = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
reach(state => state.decision?.kind === 'richeseCache');
act('r', { type: 'decision', event: game.richeseBidding!.event, card: 'richese-semuta-drug',
method: 'onceAround', direction: 'counterclockwise' });
reach(state => !!state.richeseAuction && !state.richeseAuction.outcome &&
state.richeseAuction.active === 'a' && clean(state));
act('a', { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1 });
reach(state => player(state, 'a').hand.some(card => card.id === 'richese-semuta-drug') && clean(state));
const boughtSemuta = structuredClone(game);
reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'e' && clean(state));
act('e', { type: 'bid', amount: 1 });
reach(state => player(state, 'e').hand.some(card => card.id === karama.id) && clean(state));
const boughtKarama = structuredClone(game);
reach(state => !!state.auction && state.auction.index === 1 && state.auction.active === 'b' && clean(state));
act('b', { type: 'bid', amount: 1 });
reach(state => player(state, 'b').hand.some(card => card.id === worthless.id) && clean(state));
const boughtWorthless = structuredClone(game);
let action: Action, lot: string | undefined, originalResponse: Game['response'] = null;
let target: string | undefined;
if (use === 'purchase' || use === 'auctionPayment') {
reach(state => !!state.auction && state.auction.index === 2 && state.auction.active === 'b' && clean(state));
lot = game.auction!.cards[game.auction!.index].id;
if (use === 'purchase') action = { type: 'card', card: worthless.id, mode: 'purchase' };
else {
act('b', { type: 'bid', amount: player(game, 'b').spice + 1 });
reach(state => state.decision?.kind === 'auctionPayment' && state.decision.player === 'b' && !state.response && !state.phaseOpening);
action = { type: 'decision', karama: true, card: worthless.id };
}
} else {
reach(state => state.phase === 5 && state.active === (use === 'cancel' ? 'g' : 'a') && clean(state));
if (use === 'shipment') action = { type: 'card', card: worthless.id, mode: 'shipment', target: 'a' };
else {
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
game.players.every(p => presenceAt(p, t.id) === 0));
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
target = `${destination.id}:${sector}`;
act('g', { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 });
if (game.decision?.kind === 'guildShipment') act('g', { type: 'decision', allow: true });
assert.equal(game.response?.kind, 'guildRate'); originalResponse = structuredClone(game.response);
action = { type: 'card', card: worthless.id, mode: 'cancel' };
}
}
const beforeCost = structuredClone(game);
act('b', action);
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:conversion');
assert.equal(game.pendingKarama, null);
return { setup, boughtSemuta, boughtKarama, boughtWorthless, actions, beforeCost,
game, action, use, homeworlds, lot, target, originalResponse,
owner: 'b', holder: 'a', counter: 'e', beneficiary: 'a', guild: 'g',
worthless, karama, semuta: 'richese-semuta-drug',
provenance: [
'Original five-faction Semuta setup and native BG choices; only original undealt entropy/deck order selected.',
'Atreides pays1 for public Semuta; Emperor pays1 for normal Karama; BG pays1 for the actual normal Worthless.',
'Real native phase/lot progression and optional native Guild declaration or actual BG overbid; no played state staged.',
] };
}
export function passSemutaBgResponse(state: Game, kind = 'worthlessKarama') {
let game = state;
for (let n = 0; game.response?.kind === kind && n < 30; n++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.notEqual(game.response?.kind, kind);
return game;
}
/** Only native already-earned arrival/inspection continuations, never another
* shipment, bid, card spend or invented controller. */
export function settleSemutaBgNative(state: Game) {
let game = state;
for (let n = 0; n < 80; n++) {
if (!game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard) return game;
if (game.pendingTreacheryDiscard) throw Error('Resolve the actual fresh Semuta event first.');
if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = applyAction(game, actor.id, { type: 'passResponse' });
} else if (game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
game = applyAction(game, game.decision.player, { type: 'decision', accept: false });
else if (game.decision?.kind === 'guildShipment') game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
else if (game.decision?.kind === 'homeworldShipmentGuild') game = applyAction(game, game.decision.player,
{ type: 'decision', event: game.decision.event, allow: true });
else throw Error(`Finish only the original native suffix: ${game.decision?.kind ?? 'phase opening'}`);
}
throw Error('Original BG native suffix did not settle.');
}
/** CONTROLLED acquisition/phase fixture for a real subsequent CHOAM rediscard,
* not a synthetic discard or an original-action acquisition claim. Native
* roles/setup/stocks/wallets are genuine and are never replaced after setup. */
export function createControlledBgClaimedPriceRefund() {
let game = createGame('BGCLAIMEDCASHOUT', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
for (const [id, faction] of [['b', 'beneGesserit'], ['c', 'choam'], ['g', 'guild'], ['a', 'atreides']] as const)
joinGame(game, newPlayer(id, faction, faction));
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
game = initializeSemutaGameForAudit(game);
for (let n = 0; game.status === 'setup' && n < 300; n++) {
const next = nextSemutaGuildIncomeNativeStep(game); game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
for (const p of game.players) game.deck.push(...p.hand.splice(0));
const worthless = game.deck.find(card => card.kind === 'worthless'); assert.ok(worthless);
const karamas = game.deck.filter(card => card.effect === 'karama').slice(0, 2);
assert.equal(karamas.length, 2);
moveSemutaEmperorCard(game, worthless.id, 'b');
moveSemutaEmperorCard(game, karamas[0].id, 'c');
moveSemutaEmperorCard(game, karamas[1].id, 'g');
moveSemutaEmperorCard(game, 'richese-semuta-drug', 'c');
game.phase = 5; game.active = 'a'; game.order = ['a', 'b', 'c', 'g', 'r'];
game.movementRemaining = ['a', 'b', 'c', 'g', 'r']; game.ready = [];
game.response = null; game.decision = null; game.phaseOpening = null;
return { game, worthless, cashout: karamas[0], stop: karamas[1], holder: 'c',
owner: 'b', beneficiary: 'a', guild: 'g', semuta: 'richese-semuta-drug',
provenance: ['CONTROLLED conserved later acquisition and phase after genuine native BG/CHOAM/Richese/classic setup.',
'Original native armies/wallets retained; actual BG conversion, Semuta recovery, CHOAM cashout and Guild stop thereafter.'] };
}
+4 -11
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeNexusKullGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { createNexusChoamKullFixture, createNexusKullNativeParent, createNexusKullNestedParent,
createNexusKullAuctionParent } from './fixture-nexus-choam-kull';
createNexusKullAuctionParent, passNexusKullBorrowedVoice } from './fixture-nexus-choam-kull';
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
import { spiceDeck } from '../game/cards';
import { createNexusCardPhase } from '../game/nexus-card-phase';
@@ -243,22 +243,15 @@ void test('printed Kull discovers a suspended Nexus parent through direct and BG
});
void test('Nexus fuel needed by a live own Truthtrance battle promise cannot be accepted', () => {
const f = createNexusChoamKullFixture({ fuel: 'weapon' });
const f = createNexusChoamKullFixture({ fuel: 'weapon', actorFaction: 'atreides' });
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
Object.assign(game, { phase: 6, active: f.choam, karamaShipping: null });
for (const p of game.players) { p.forces = {}; p.reserves = 20; }
for (const p of game.players.slice(0, 2)) { p.forces = { 'arrakeen:10': 3 }; p.reserves = 17; }
game = applyAction(game, f.choam, { type: 'chooseBattle', territory: 'arrakeen', target: f.actor });
for (let step = 0; game.response && step < 20; step++) {
const p = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
game = p ? applyAction(game, p.id, { type: 'passResponse' }) : normalizeAutomaticGame(game);
}
game.battle!.preparation = undefined;
game = passNexusKullBorrowedVoice(game);
game.battle!.truthPromises = [{ player: f.choam, asker: f.other!, claim: { kind: 'weapon', name: 'Hunter Seeker' }, answer: true }];
game.response = { kind: 'eliteStrength', owner: f.actor, passed: [] };
game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' });
const offered = applyAction(game, f.other!, { type: 'card', mode: 'cancel', card: f.counter! });
assert.equal(viewGame(offered, f.choam).kullReaction!.plays.some(play => play.card.id === f.fuel), false);
reject(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, source: 'nexus', card: f.fuel });
+157
View File
@@ -0,0 +1,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import {
createOriginalSemutaBg, createControlledBgClaimedPriceRefund,
passSemutaBgResponse, settleSemutaBgNative, type SemutaBgUse,
} from './fixture-semuta-bg-karama';
import { reactSemutaSpecial } from './fixture-semuta-special-karama';
import { passSemutaSpecialDiscards } from './fixture-semuta-emperor-share';
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function ordinaryPriceShipment(game: Game): Action {
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
game.players.every(p => presenceAt(p, t.id) === 0));
assert.ok(destination);
return { type: 'ship', territory: destination.id, sector: destination.sectors.find(s => s !== game.storm)!,
amount: 3, elite: 0, allyPayment: 0 };
}
for (const use of ['shipment', 'cancel', 'purchase', 'auctionPayment'] as SemutaBgUse[])
void test(`original BG ${use} keeps both fresh costs and its original native allowance or denial`, () => {
for (const claim of [false, true]) for (const canceled of [false, true]) {
const f = createOriginalSemutaBg(use), before = structuredClone(f.beforeCost);
let game = f.game;
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:conversion');
assert.equal(game.pendingKarama, null);
if (use === 'shipment') assert.equal(game.karamaShipping, null);
reject(game, f.owner, f.action);
game = reactSemutaSpecial(game, f.holder, claim);
assert.equal(game.response?.kind, 'worthlessKarama');
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
assert.ok(viewGame(game, f.counter).responseControls!.cancelCards.includes(f.karama.id));
if (canceled) {
game = applyAction(game, f.counter, { type: 'card', mode: 'cancel', card: f.karama.id });
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:worthlessKarama');
game = reactSemutaSpecial(game, f.holder, !claim);
assert.equal(player(game, f.holder).hand.some(c => c.id === f.karama.id), !claim);
} else game = passSemutaBgResponse(game);
assert.equal(game.pendingKarama, null);
const recovered = player(game, f.holder).hand.find(c => c.id === f.worthless.id);
assert.equal(!!recovered, claim);
if (recovered) assert.deepEqual(recovered, f.worthless);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, claim || canceled ? 1 : 0);
if (use === 'shipment') {
if (canceled) {
assert.equal(game.karamaShipping, null);
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice);
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves);
assert.equal(player(game, f.beneficiary).shipped, false);
} else {
assert.equal(viewGame(game, f.beneficiary).karamaShipping!.owner, f.owner);
const shipment = ordinaryPriceShipment(game);
game = settleSemutaBgNative(applyAction(game, f.beneficiary, shipment));
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice - 3);
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves - 3);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice);
assert.equal(player(game, f.beneficiary).forces[`${String(shipment.territory)}:${Number(shipment.sector)}`], 3);
assert.equal(game.karamaShipping, null);
}
} else if (use === 'cancel') {
// Original already-passed seats stay passed: native normalization may
// finish this restored response automatically. Assert its actual price.
if (game.response?.kind === 'guildRate') game = passSemutaBgResponse(game, 'guildRate');
game = settleSemutaBgNative(game);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice - (canceled ? 2 : 4));
assert.equal(player(game, f.guild).reserves, player(before, f.guild).reserves - 2);
assert.equal(player(game, f.guild).forces[f.target!], 2);
assert.equal(player(game, f.guild).shipped, true);
assert.equal(player(game, f.guild).moved, 0);
} else {
assert.equal(player(game, f.owner).hand.filter(c => c.id === f.lot).length, canceled ? 0 : 1);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
if (canceled) assert.equal(game.auction!.cards[game.auction!.index].id, f.lot);
}
}
});
void test('an original converted price retains exact Caladan withdrawal and bank-only world payment after cost recovery', () => {
for (const claim of [false, true]) {
const f = createOriginalSemutaBg('shipment', true);
const before = structuredClone(f.beforeCost);
let game = passSemutaBgResponse(reactSemutaSpecial(f.game, f.holder, claim));
const quote = homeworldShipmentChoice(viewGame(game, f.beneficiary), 'homeworld:emperor',
{ 'homeworld:atreides': { normal: 3, elite: 0 } });
assert.ok(quote.action, quote.blocked ?? undefined); assert.equal(quote.cost, 2);
game = settleSemutaBgNative(applyAction(game, f.beneficiary, quote.action));
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice - 2);
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves - 3);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice);
assert.deepEqual(game.homeworlds!.custody!.visitors['homeworld:emperor'][f.beneficiary], { normal: 3, elite: 0 });
assert.equal(player(game, f.holder).hand.some(c => c.id === f.worthless.id), claim);
assert.equal(game.karamaShipping, null);
}
});
void test('a controlled real CHOAM rediscard cannot revive refund eligibility for a recovered converted price cost', () => {
const f = createControlledBgClaimedPriceRefund();
let game = applyAction(f.game, f.owner, { type: 'card', card: f.worthless.id,
mode: 'shipment', target: f.beneficiary });
game = passSemutaBgResponse(reactSemutaSpecial(game, f.holder, true));
const cashoutSpice = player(game, f.holder).spice;
game = applyAction(game, f.holder, { type: 'card', mode: 'special', card: f.cashout.id,
cards: [f.worthless.id] });
game = passSemutaSpecialDiscards(game);
assert.equal(player(game, f.holder).spice, cashoutSpice + 3);
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
const before = structuredClone(player(game, f.beneficiary));
game = applyAction(game, f.beneficiary, ordinaryPriceShipment(game));
game = applyAction(game, f.guild, { type: 'card', mode: 'special', card: f.stop.id });
game = passSemutaSpecialDiscards(game);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
assert.equal(player(game, f.beneficiary).spice, before.spice);
assert.equal(player(game, f.beneficiary).reserves, before.reserves);
assert.equal(player(game, f.beneficiary).shipped, true);
assert.equal(player(game, f.beneficiary).moved, 0);
assert.equal(game.karamaShipping, null);
});
void test('recovering a Worthless does not grant its new faction the native BG conversion role', () => {
const f = createOriginalSemutaBg();
let game = reactSemutaSpecial(f.game, f.holder, true);
game = applyAction(game, f.counter, { type: 'card', card: f.karama.id, mode: 'cancel' });
game = reactSemutaSpecial(game, f.holder, false);
assert.equal(game.karamaShipping, null);
assert.equal(game.active, f.beneficiary);
assert.equal(player(game, f.beneficiary).shipped, false);
assert.deepEqual(player(game, f.holder).hand.find(c => c.id === f.worthless.id), f.worthless);
reject(game, f.holder, { type: 'card', card: f.worthless.id, mode: 'shipment', target: f.beneficiary });
});
void test('changed source and stale conversion/counter events cannot redirect the original physical cost or power', () => {
const f = createOriginalSemutaBg();
const event = f.game.pendingTreacheryDiscard!.batch.event;
reject(f.game, f.owner, { type: 'semutaCommit', event });
const changed = structuredClone(f.game);
const continuation = changed.pendingTreacheryDiscard!.continuation;
assert.ok(continuation?.kind === 'bgKaramaCostDiscard' && continuation.use.kind === 'shipment');
continuation.use.recipient = f.guild;
reject(changed, f.holder, { type: 'semutaCommit', event });
let game = reactSemutaSpecial(f.game, f.holder, false);
game = applyAction(game, f.counter, { type: 'card', card: f.karama.id, mode: 'cancel' });
reject(game, f.holder, { type: 'semutaCommit', event });
game = reactSemutaSpecial(game, f.holder, true);
assert.equal(game.karamaShipping, null);
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.karama.id).length, 1);
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
});