Connect fresh Basic Semuta and Ambassador discards
Initialize the existing neutral Semuta protocol during genuine fresh public bare Basic Richese setup with selected native families. Retain original roster/deck/freshness checks, separate optional/Advanced profiles and unchanged existing saves. Retain the original Ambassador arrival cause and admit clean ordinary shipment/movement CHOAM and Ixian private discard batches, including native BG copies. Keep CHOAM credit committed once; pause before exactly one Ix replacement. Existing owner-only commitment/selection, physical Semuta/full-hand exchange and original entrant continuation remain. Verify original public setup, actual one-spice Semuta auction, ordinary purchase, paid placement, paid shipment and actual movement. Exercise claim/pass controls, busy protection and all four existing legal profiles without strategic tuning. Repair nearby stale tests to acknowledge actual battle outcomes and retain the existing independent-card/Discovery combination. Full modes, wider nested/optional interactions, comprehensive assurance and deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
This commit is contained in:
1 parent
1f3e71fe03
commit
b4a556d5d3
12 files changed
+673
-34
No files matched your search
@@ -1255,7 +1255,13 @@ export function GameTable({
|
||||
) : (
|
||||
<>
|
||||
Semuta Drug development preview · bounded fresh-discard reactions,
|
||||
not complete Richese or certified expansion rules. Original Tech Tokens
|
||||
not complete Richese or certified expansion rules. Fresh bare Basic
|
||||
Richese starts enable these reactions with their selected native
|
||||
families; older games are unchanged. Clean CHOAM and Ixian Ambassador
|
||||
discards, including BG copies, now pause after ordinary shipment or
|
||||
movement entry. CHOAM keeps its already-earned spice; Ixians draw their
|
||||
one replacement after the reaction. Other nested arrivals remain
|
||||
unfinished. Original Tech Tokens
|
||||
may remain in supported fresh 3–6-seat CHOAM/Ix profiles; the original
|
||||
winner’s award follows card cleanup and precedes Face Dance. Original
|
||||
Stronghold Cards may remain in Advanced 2–6-seat profiles and are claimed
|
||||
|
||||
@@ -16,6 +16,28 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Fresh Basic Semuta and Ambassador discards — 24 October. Prototyped / Partial.**
|
||||
Fresh bare public Basic Richese previews initialize Semuta before genuine setup
|
||||
with selected native families. Optional/Advanced profiles and older games keep
|
||||
their existing scope. Clean native/BG-copy CHOAM and Ixian private discards after
|
||||
source-witnessed ordinary shipment/movement park the original entry. CHOAM keeps
|
||||
its single credit; Ixians draw once after claim/Continue. Existing committed-only
|
||||
selection and full-hand exchange remain; controls persist without a timer.
|
||||
|
||||
A disjoint original-action fixture lane, bounded dependency review and owner
|
||||
integration pass **12 files142/142**, types/lint/build, ten actual-control/table
|
||||
rendering programmes and four existing profiles with20 legal proposals. Sources
|
||||
58/59 use genuine public Basic setup, 35 main plus10 cache, actual Semuta/card
|
||||
purchases, paid placement/shipment and two actual ground-movement arrivals.
|
||||
Only unopened positions and original scalar/packet lotteries are selected;
|
||||
separate full-hand/own-card edge tests explicitly transfer conserved cards.
|
||||
Nearby stale tests now acknowledge actual battle results and retain the existing
|
||||
independent-card/Discovery composition; representative failures reproduced on
|
||||
the previous engine. Fixture-only type feedback was repaired.
|
||||
[Current scope, exact proof and remaining limits](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026).
|
||||
Full modes, nested/provenance combinations, comprehensive assurance and deployed
|
||||
acceptance remain open. No strategy tuning or saved-room/reset/restart/deployment.
|
||||
|
||||
**Bare Basic native Ecaz coalitions — 24 October. Prototyped / Partial.**
|
||||
Fresh public Basic Ecaz now admits selected native E1/E2 families while retaining
|
||||
original setup/roster and actual battle guards. Include allied zero/mixed Richese
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Fresh Basic Semuta and Ambassador discards
|
||||
|
||||
Initialize the existing Semuta protocol before genuine fresh public bare Basic
|
||||
Richese setup with selected native families. Keep optional/Advanced profiles and
|
||||
older games unchanged. Retain the actual Ambassador arrival cause; clean
|
||||
shipment/movement CHOAM and Ixian private discards, including native BG copies,
|
||||
park the original entry. CHOAM credit stays committed once; Ixians draw exactly
|
||||
once after the reaction. Existing private commitment/selection, physical Semuta,
|
||||
approved full-hand exchange and original entrant suffix remain.
|
||||
|
||||
A disjoint original-action fixture lane, bounded dependency review and owner
|
||||
integration pass twelve-file142/142, types/lint/build, ten actual-control/table
|
||||
rendering programmes and four existing profiles with20 legal proposals. Real
|
||||
public setup, actual Semuta auction/card purchase, paid placement/shipment and
|
||||
two actual movement arrivals use58/59 source actions and35 main plus10 cache.
|
||||
Only unopened positions and native scalar/packet lotteries are selected. The
|
||||
separate full-hand/own-card tests disclose controlled conserved transfers.
|
||||
Eight stale battle cases now acknowledge the actual result; the old module
|
||||
rejection now retains the already-supported independent-card/Discovery setup.
|
||||
Representative failures reproduced on the previous engine. Fixture-only type
|
||||
feedback was repaired; frozen source passes without a rule workaround.
|
||||
[Source, current scope and exact proof](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026).
|
||||
Full modes, nested/provenance combinations, comprehensive assurance and deployed
|
||||
acceptance remain open. No strategy tuning or saved-room/reset/restart/deployment.
|
||||
|
||||
## 24 October 2026 — Bare Basic native Ecaz coalitions
|
||||
|
||||
Remove the conservative Basic native-family admission restriction while keeping
|
||||
|
||||
@@ -907,7 +907,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Advanced Kull specific effect | Supplied physical29/logical27 restricts the attempted Karama for a specific effect, unlike publisher Basic phase-wide wording. New signed Advanced attempts bind semantic purpose; purchase/payment and native special variants normalize, as do enhanced Prescience/advisor conversion. CHOAM effects and special-force faction powers stay distinct. Retaining turn/phase expiry is explicit provisional composition under the 14 October policy, not supplied wording. Legacy unstamped pending/restriction frames retain broad scope. Prior distinct counter, BG-before-conversion and deferred overbid selections stay. | [Source cutover and keys](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026), [connected implementation and limits](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026) |
|
||||
| Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) |
|
||||
| Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) |
|
||||
| Semuta / native Basic Terror | Fresh local `semuta` additionally admits Basic Richese/Moritani plus classics, CHOAM/Ecaz decks and no optional modules. Private Sabotage/Robbery overflow after clean ordinary shipment/movement arrivals uses the existing neutral all-seat response and committed-only face access. Original victim/draw, removed token, payment, forces and optional gift remain original. Other arrival parents, Advanced Moritani and wider family mixtures remain implementation boundaries, not printed Semuta prohibitions. | [Source and bounded evidence](SEMUTA_SOURCE_UPDATE_20260907.md), [native suffix](TERROR_DISCARD_CONTINUATIONS.md) |
|
||||
| Semuta / native Basic entries | Fresh bare public Basic Richese previews initialize the existing protocol before genuine setup with selected native E1/E2/E3 families; no optional/Advanced inheritance or old-game retrofit. Clean source-witnessed shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: keep CHOAM's committed credit and draw the Ixian replacement only after the reaction. This composes printed component rules, not a new timing FAQ. Existing Basic Terror overflow retains the neutral all-seat response, committed-only face access, original victim/draw, removed token, payment, forces and optional gift. Nested parents, completed movement-card provenance and wider module/full-game coverage remain unfinished implementation boundaries, not printed Semuta prohibitions. | [Fresh Basic and Ambassador scope](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026), [native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md) |
|
||||
|
||||
Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question
|
||||
truth: Yes to AND requires all, Yes to OR requires one, and No negates the whole.
|
||||
|
||||
@@ -2,6 +2,76 @@
|
||||
|
||||
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.
|
||||
|
||||
## Fresh Basic starts and Ambassador discards — 24 October 2026
|
||||
|
||||
Fresh public **bare Basic Richese previews** now initialize Semuta before the
|
||||
original setup and normalization. Two through six native seats use only their
|
||||
selected E1/E2/E3 families and the genuine physical decks/cache. No optional
|
||||
modules or independent E3 card variant inherit this route; Advanced and those
|
||||
modules retain their existing separate entry contracts. Existing games are not
|
||||
retrofitted. This connects current Basic preview behavior, not complete Richese,
|
||||
expansion or mode certification.
|
||||
|
||||
Clean native **CHOAM and Ixian Ambassador** discards, including actual BG copies,
|
||||
now offer the same neutral reaction. A fresh entry retains its original arrival
|
||||
cause. Only phase-five shipment/movement entries with no active nested arrival,
|
||||
worm, relocation, transport, revival or purchase parent enter this scope. Older
|
||||
frames without that witness retain their original immediate continuation.
|
||||
Completed movement-card provenance and all other nested combinations remain
|
||||
unfinished interaction coverage; cause admission does not certify them.
|
||||
|
||||
The existing [discard inventory](SEMUTA_DISCARD_CONTINUATIONS.md) already defines
|
||||
the order: CHOAM's selected private cards form one simultaneous batch **after**
|
||||
its single three-spice-per-card credit; Ixians' one private discard pauses
|
||||
**before** its one replacement draw. The whole original Ambassador entry stays
|
||||
parked. Claim or all-seat Continue retires that fresh batch and resumes the
|
||||
original entrant once. A CHOAM empty selection creates no event or income.
|
||||
This is printed-component composition with the existing Semuta protocol, not a
|
||||
new publisher timing FAQ. The approved full-hand exchange is unchanged.
|
||||
|
||||
No private face is projected before commitment; only the committed owner sees
|
||||
eligible other-player cards. A multiple-card group requires exactly one claim;
|
||||
one candidate settles immediately. Ixians' reserved replacement belongs to the
|
||||
discarding beneficiary, who cannot claim their own batch, so it does not consume
|
||||
the distinct claimant's slot. Physical Semuta is spent once. The persistent
|
||||
Continue/claim controls use the existing reaction component and never time out.
|
||||
|
||||
Original-action evidence uses actual public Basic Ecaz/CHOAM/Richese/Guild setup,
|
||||
35 ordinary plus10 native cache cards, Guild's real one-spice Semuta auction,
|
||||
Ecaz's one-spice ordinary purchase and one-spice placement, then Guild's paid
|
||||
one-force entry. Only unopened deal/Spice positions and original scalar/packet
|
||||
lotteries are selected; no dealt hand, wallet, force, clock, marker or pending
|
||||
receipt is installed. Two sources additionally make a real ordinary ground move
|
||||
from the paid shipment to the Ambassador. Full-hand and own-card edge cases
|
||||
explicitly stage conserved card transfers and are labelled controlled.
|
||||
|
||||
The twelve-file affected union passes **142/142**, including17 new behavior
|
||||
cases, and types/lint/build pass. Ten actual gameplay/control/rendering programmes
|
||||
cover four native/copy grants through both claim/pass branches plus two real
|
||||
movement entries. Sources use58/59 actual actions. The real persistent
|
||||
`SemutaReaction` handlers exercise Continue, commit, private selection and busy
|
||||
protection; actual `GameTable` server rendering covers the grant, offer, selection,
|
||||
resumption and original entrant completion. Four existing profiles make20 legal
|
||||
reaction proposals. Framework aliases and a CSS stub make this server-rendered
|
||||
control evidence, not browser/visual/deployed acceptance. No strategy is tuned.
|
||||
|
||||
Feedback found eight old Semuta battle cases skipping the now-required result
|
||||
acknowledgements, plus an old Advanced-preview test still rejecting the existing
|
||||
independent-card/Discovery combination. Representative failures reproduced
|
||||
against the preceding committed engine. Tests now follow actual acknowledgements
|
||||
and retain both existing modules. Subsequent fixture-only type feedback was
|
||||
corrected; the frozen union and runtime pass without an engine workaround.
|
||||
|
||||
Exact frozen base `1f3e71fe033deb18470f8bc0f29a96dfa01e8578`, tree
|
||||
`2a379f47d4b789a486b198ddff4b17433086e824f5ce3919bb3aad7c11b2868f`,
|
||||
2621 source files. Private logs, original-action HTML/receipts and source hashes:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-basic-semuta-ambassador/`,
|
||||
`frozen-integrated-3/report.json`. Final checkpoint prose/reference overlays are
|
||||
separately recorded; engine, controls and tests remain identical to that proof.
|
||||
Full modes, wider producers/modules, nested originating-card combinations,
|
||||
comprehensive assurance and deployed acceptance remain open. No worker/D1,
|
||||
saved-room operation, reset, restart or deployment.
|
||||
|
||||
## Shrine physical-role costs — 16 October 2026
|
||||
|
||||
The private `semuta --discoveries` entry now composes original fresh Richese/
|
||||
|
||||
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
|
||||
if (definition?.card.effect === 'distrans')
|
||||
return 'Choose a recipient and card in the Distrans transfer panel.';
|
||||
if (definition?.card.effect === 'semutaDrug')
|
||||
return 'Use the neutral fresh-discard response when offered in the selected Semuta development profile. Its connected producers are listed in the table preview and rules guide. Commit before inspecting private discarded faces; choose exactly one when the fresh group has multiple eligible cards. Taking a card never refunds a paid cost or replays its original effect. The approved full-hand exchange spends Semuta without increasing the completed hand.';
|
||||
return 'Use the neutral fresh-discard response when offered. Fresh bare Basic Richese previews enable it with their selected native families; older games and separate module previews keep their existing scope. Connected producers are listed in the table preview and rules guide. Commit before inspecting private discarded faces; choose exactly one when the fresh group has multiple eligible cards. Taking a card never refunds a paid cost or replays its original effect. The approved full-hand exchange spends Semuta without increasing the completed hand.';
|
||||
return definition && definition.card.effect !== 'karama'
|
||||
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
||||
: null;
|
||||
@@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
||||
richese.card.effect === 'juiceOfSapho'
|
||||
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
|
||||
: richese.card.effect === 'semutaDrug'
|
||||
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity, native capture/Auditor/battle-income/Ix-substitution/Basic allied-retention powers, or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor) and private normal-auction or Black Market Ixian ally replacements. Acquiring a printed cancellation cost never restores its canceled effect. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after the original choice and Karama response, preserving its kept card and unchanged original retention pool. The Basic Richese/Moritani CHOAM/Ecaz entry also adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.'
|
||||
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity, native capture/Auditor/battle-income/Ix-substitution/Basic allied-retention powers, or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor) and private normal-auction or Black Market Ixian ally replacements. Acquiring a printed cancellation cost never restores its canceled effect. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after the original choice and Karama response, preserving its kept card and unchanged original retention pool. The Basic Richese/Moritani CHOAM/Ecaz entry also adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Fresh bare Basic Richese previews now initialize these reactions with selected native E1/E2/E3 families. Clean native or BG-copy CHOAM and Ixian Ambassador private discards after ordinary shipment/movement retain the committed payout or pause before the replacement draw. Old games are unchanged; optional/Advanced entries retain their existing scope. Other pending rewards, producers and nested arrivals remain unfinished.'
|
||||
: richese.card.effect === 'mirrorWeapon'
|
||||
? 'Mirror Weapon works in supported CHOAM/Richese-deck tables, including selected Ix/CHOAM with original Tech3+ and Advanced Strongholds2+, or existing Advanced native Ecaz/Moritani. Copied Tooth or Stone chooses before its original; a winning physical Mirror may be kept after copied Tooth or Artillery under the user-selected retention interpretation. Original Stone/Tooth uses provisional Stone-first. Typed public allocation safety, mixed E3 phase-module and other profile/full-start guards remain.'
|
||||
: richese.card.effect === 'stoneBurner'
|
||||
|
||||
+57
-18
@@ -1725,6 +1725,8 @@ export type Game = {
|
||||
richeseContribution?: number;
|
||||
} | null;
|
||||
pendingAmbassador?: {
|
||||
/** Fresh entries retain their actual arrival source; older frames omit it. */
|
||||
arrivalCause?: NonNullable<Game['pendingTerrorEntry']>['cause'];
|
||||
revivalEvent?: string;
|
||||
victoryEvent?: string;
|
||||
event: string;
|
||||
@@ -7525,6 +7527,20 @@ function semutaOfferSupported(
|
||||
) {
|
||||
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
|
||||
continuation.kind === 'karamaBgConversionCancelDiscard';
|
||||
const ambassadorDiscard = continuation.kind === 'ambassador';
|
||||
if (ambassadorDiscard &&
|
||||
(!bareBasicSemutaProfile(g) || g.phase !== 5 ||
|
||||
continuation.entry.stage !== 'cards' || continuation.entry.resume !== 'none' ||
|
||||
!['shipment', 'movement'].includes(continuation.entry.arrivalCause ?? '') ||
|
||||
!['choam', 'ixians'].includes(continuation.entry.effect ?? '') ||
|
||||
continuation.entry.guildAdvisorOrigin || continuation.entry.revivalEvent ||
|
||||
continuation.entry.victoryEvent || continuation.entry.relocation ||
|
||||
continuation.entry.shipmentReceipt || continuation.entry.purchaseReceipt ||
|
||||
continuation.entry.revivalReceipt || continuation.entry.nexusPurchase ||
|
||||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
|
||||
g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.pendingRevival)) return false;
|
||||
if (continuation.kind === 'karamaBgConversionCancelDiscard' &&
|
||||
!semutaBgConversionCancelResponse(g, continuation.use.response, continuation.resume.pendingKarama)) return false;
|
||||
if (continuation.kind !== 'ordinaryCardDiscard' &&
|
||||
@@ -7549,6 +7565,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentPriceDiscard' &&
|
||||
continuation.kind !== 'specialKaramaDiscard' &&
|
||||
!ambassadorDiscard &&
|
||||
!bgDiscard &&
|
||||
continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
@@ -7661,7 +7678,7 @@ function semutaOfferSupported(
|
||||
return g.semutaPreview === true && g.status === 'playing' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
entries.length > 0 &&
|
||||
(continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ||
|
||||
(ambassadorDiscard || continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ||
|
||||
(continuation.kind === 'specialKaramaDiscard' && continuation.stage === 'cashout')
|
||||
? entries.every(entry => !entry.publicFace)
|
||||
: continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' ||
|
||||
@@ -7696,6 +7713,7 @@ function semutaOfferSupported(
|
||||
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
|
||||
: true) &&
|
||||
(continuation.kind === 'battleCleanup' ||
|
||||
ambassadorDiscard ||
|
||||
bgDiscard ||
|
||||
continuation.kind === 'specialKaramaDiscard' ||
|
||||
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
@@ -8598,7 +8616,7 @@ function startModules(state: Game): Game {
|
||||
if (g.homeworlds)
|
||||
return g.expansions.length ? initializeHomeworldOccupationGameForAudit(g) : initializeHomeworldGameForAudit(g);
|
||||
if (g.strongholdCards) return initializeStrongholdFactionsGameForAudit(g);
|
||||
return initializeFactionExpansionsGameForAudit(g);
|
||||
return initializeFactionExpansionsGame(g, bareBasicSemutaProfile(g));
|
||||
}
|
||||
/** Public consistency only; each module's genuine initializer validates its own envelope. */
|
||||
function baseStartProfileBlock(g: Game): string | null {
|
||||
@@ -10742,6 +10760,11 @@ export function initializeIxGameForAudit(state: Game): Game {
|
||||
}
|
||||
/** Genuine faction-only development setup. Optional variants and public starts remain gated. */
|
||||
export function initializeFactionExpansionsGameForAudit(state: Game): Game {
|
||||
return initializeFactionExpansionsGame(state);
|
||||
}
|
||||
function initializeFactionExpansionsGame(state: Game, semuta = false): Game {
|
||||
requireRule(!semuta || state.semutaPreview === undefined,
|
||||
'Fresh public Semuta starts cannot inherit an existing preview marker.');
|
||||
requireRule(typeof state.advanced === 'boolean', 'Choose Basic or Advanced rules for the faction prototype.');
|
||||
requireRule(Array.isArray(state.expansions) && state.expansions.length > 0 &&
|
||||
state.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
|
||||
@@ -10752,7 +10775,7 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
|
||||
(!state.techTokens || nativeFactionTechProfile(state)) && !state.strongholdCards,
|
||||
'The faction prototype excludes other optional modules; Tech Tokens require a supported native three-through-six-seat lobby.');
|
||||
requireFreshFactionInventory(state);
|
||||
const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
|
||||
const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, false, false, semuta);
|
||||
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
|
||||
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
|
||||
return initialized;
|
||||
@@ -10869,23 +10892,32 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
|
||||
'Assassination requires Advanced Moritani in a supported selected native roster and module composition.');
|
||||
return initializeFactionExpansionsGameForAudit(state);
|
||||
}
|
||||
/** Development-only Semuta with original native modules/skills, separate bounded
|
||||
* Homeworld occupation, or Basic Moritani/E3. Public starts stay gated. */
|
||||
/** Bare native Basic starts share the original deck/roster validation. Optional
|
||||
* modules retain their own explicit Semuta envelopes; saved games are not upgraded. */
|
||||
function bareBasicSemutaProfile(g: Game): boolean {
|
||||
return g.advanced === false && g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.expansions.includes('choam') && !!byFaction(g, 'richese') &&
|
||||
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
|
||||
new Set(g.expansions).size === g.expansions.length &&
|
||||
g.players.every(p => faction(p.faction).expansion === 'base' ||
|
||||
g.expansions.includes(faction(p.faction).expansion)) &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
|
||||
!g.strongholdCards && !g.ecazTreachery && !g.discoveryEnabled &&
|
||||
!g.discoveries && !g.discoveryStash && !g.greatMaker;
|
||||
}
|
||||
/** Explicit development entry for original native modules/skills, separate
|
||||
* Homeworld occupation or bare Basic selected native families. */
|
||||
export function initializeSemutaGameForAudit(state: Game, skills = false): Game {
|
||||
const nativeTerror = !state.advanced && state.expansions.length === 2 &&
|
||||
state.expansions.includes('choam') && state.expansions.includes('ecaz') &&
|
||||
state.players.some(p => p.faction === 'moritani') &&
|
||||
state.players.every(p => p.faction === 'richese' || p.faction === 'moritani' ||
|
||||
faction(p.faction).expansion === 'base');
|
||||
const basicNative = bareBasicSemutaProfile(state);
|
||||
requireRule(!state.semutaPreview &&
|
||||
state.expansions.includes('choam') &&
|
||||
(state.expansions.every(id => id === 'choam' || id === 'ix') || nativeTerror) &&
|
||||
(state.expansions.every(id => id === 'choam' || id === 'ix') || basicNative) &&
|
||||
state.players.some(p => p.faction === 'richese'),
|
||||
'Semuta needs a fresh Richese faction game: CHOAM with optional Ix and supported original modules/skills; separate exact-CHOAM Homeworld occupation has no other modules, while Basic Moritani uses CHOAM/Ecaz and classics without modules. Other overlays remain excluded.');
|
||||
if (state.discoveryEnabled) requireRule(!nativeTerror && !skills && !state.homeworlds &&
|
||||
'Semuta needs a fresh Richese faction game: bare Basic selected native families, or CHOAM with optional Ix and supported original modules/skills; separate exact-CHOAM Homeworld occupation has no other modules. Other overlays remain excluded.');
|
||||
if (state.discoveryEnabled) requireRule(!basicNative && !skills && !state.homeworlds &&
|
||||
!state.nexusCards && discoveryModuleProfile(state),
|
||||
'Semuta Discovery keeps original Richese/CHOAM and optional Ix setup, without Skills, Homeworlds, Nexus or E3 overlays.');
|
||||
if (state.nexusCards) requireRule(!nativeTerror && !state.homeworlds &&
|
||||
if (state.nexusCards) requireRule(!basicNative && !state.homeworlds &&
|
||||
!state.discoveryEnabled && !state.discoveries,
|
||||
'Semuta Nexus retains original E1/E2 rosters without Homeworlds, Discovery or E3.');
|
||||
if (state.homeworlds) requireRule(!skills && semutaHomeworldRoster(state),
|
||||
@@ -11051,7 +11083,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
|
||||
return initialized;
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false, strongholdFactions = false): Game {
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false, strongholdFactions = false, semuta = false): Game {
|
||||
nexusCardsIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -11154,6 +11186,11 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
}
|
||||
if (byFaction(g, 'guild')) initializeGuildBetrayalState(g);
|
||||
}
|
||||
if (semuta) {
|
||||
requireRule(factions && bareBasicSemutaProfile(g),
|
||||
'Fresh public Semuta setup requires a bare Basic Richese roster.');
|
||||
g.semutaPreview = true;
|
||||
}
|
||||
initializeSetup(g, strongholdFactions || !!g.strongholdCards);
|
||||
if (nativeTech && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
|
||||
return normalizeAutomaticGame(g);
|
||||
@@ -16743,8 +16780,10 @@ function arrivalOverlapIntegrity(g: Game) {
|
||||
function openAmbassadorEntry(
|
||||
g: Game, owner: Player, token: AmbassadorToken, entrant: Player, to: string,
|
||||
sector: number, resume: 'none' | 'wormRide',
|
||||
arrivalCause?: NonNullable<Game['pendingTerrorEntry']>['cause'],
|
||||
): boolean {
|
||||
g.pendingAmbassador = {
|
||||
...(arrivalCause ? { arrivalCause } : {}),
|
||||
event: `ambassador:${g.turn}:${g.phase}:${(g.log.at(-1)?.seq ?? 0) + 1}`,
|
||||
owner: owner.id,
|
||||
entrant: entrant.id,
|
||||
@@ -16829,7 +16868,7 @@ function openTerritoryEntry(
|
||||
requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador,
|
||||
'Finish the other arrival reaction before opening this entry.');
|
||||
if (!terrorTokens.length)
|
||||
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume);
|
||||
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause);
|
||||
requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap &&
|
||||
moritani && g.order.includes(owner.id) && g.order.includes(moritani.id),
|
||||
'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.');
|
||||
@@ -16846,7 +16885,7 @@ function openTerritoryEntry(
|
||||
log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`);
|
||||
if (first === 'terror')
|
||||
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none');
|
||||
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none');
|
||||
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause);
|
||||
overlap.ambassadorEvent = g.pendingAmbassador!.event;
|
||||
return true;
|
||||
}
|
||||
@@ -18996,7 +19035,7 @@ function finishTerrorEntry(g: Game) {
|
||||
t.location === overlap.territory);
|
||||
requireRule(token, 'The originally triggered Ambassador token is no longer present.');
|
||||
openAmbassadorEntry(g, getPlayer(g, overlap.ecaz), token,
|
||||
getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none');
|
||||
getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none', overlap.cause);
|
||||
overlap.ambassadorEvent = g.pendingAmbassador!.event;
|
||||
return;
|
||||
}
|
||||
|
||||
+8
-6
@@ -1305,7 +1305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Reactive Baliset and La La La also offer their used card after the accepted cost. The original uncommitted move or free-revival request is already stopped: claim or Continue installs the original restriction without moving forces, consuming movement, reviving or charging the denied request. Another legal move or paid revival remains available. Movement-card, Ambassador, Nexus and other competing parents remain separate.',
|
||||
'A clean proactive Trip to Gamont also pauses for a selected elite or concealed No-Field. Claim or Continue returns the exact selected physical counter, or performs the native No-Field reveal and returns at most one actual force after reserve-capped materialization. A zero token reveals without inventing a force; taking the used card does not undo that effect or spend revival usage.',
|
||||
'Trip to Gamont in CHOAM’s final Mentat opportunity also offers the used card. Claim or Continue completes the selected return and restores that same CHOAM-owned opportunity. It does not check victory or advance the turn: CHOAM must still choose Finish and check victory, using the changed board.',
|
||||
'The same local semuta entry also admits Basic native Richese and Moritani with classic opponents, exactly CHOAM/Ecaz decks and no optional modules. A clean shipment/movement Sabotage or Robbery overflow offers its private fresh discard without showing the face before commitment. Claim or Continue preserves the original victim/draw, removed token, shipment payment and forces; Sabotage may then offer its original optional gift. Advanced Moritani, native Ecaz, Ix/E3 mixtures and worm/Ambassador/overlap arrivals remain outside this bounded entry. The table visibly labels the development preview.',
|
||||
'Fresh bare public Basic Richese previews now initialize Semuta before genuine setup with selected native E1/E2/E3 families. Older games are unchanged; Advanced and optional modules retain their existing separate entries. Clean ordinary shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: CHOAM keeps its already-earned credit and Ixians draw one replacement after the reaction. A multiple-card private group requires commitment before exactly one selection. Original entrant actions then resume once. Existing Basic Sabotage/Robbery overflow keeps the original victim/draw, removed token, payment, forces and optional gift. Nested arrivals, completed movement-card provenance, wider module coverage and complete modes remain unfinished. The table labels the preview and its limits.',
|
||||
] : []),
|
||||
],
|
||||
...(definition.card.effect === 'semutaDrug'
|
||||
@@ -1315,32 +1315,34 @@ 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. Native BG physical Worthless cost now precedes the single original conversion emitter; the separately printed cancellation cost retains that pending use and still denies it. All four use bindings, incoming reservations and older passes remain. Allowed converted prices carry claimed-cost provenance through actual later rediscard/refund. Fresh private Discovery composition now connects both physical Shrine-role costs without changing original effects or printed identity.",
|
||||
"The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner. Printed native special costs now resume one committed native effect through effect/complete/cashout stages. Original Emperor post-revival disposal is preserved; CHOAM selected cards are a later private simultaneous batch after one credit. Existing native once-use, joint ordering and typed parent remain. Native BG physical Worthless cost now precedes the single original conversion emitter; the separately printed cancellation cost retains that pending use and still denies it. All four use bindings, incoming reservations and older passes remain. Allowed converted prices carry claimed-cost provenance through actual later rediscard/refund. Fresh private Discovery composition now connects both physical Shrine-role costs without changing original effects or printed identity. Fresh bare public Basic starts now initialize Semuta with selected native families. Clean CHOAM/Ixian Ambassador private batches, including BG copies, retain the original entry, already-earned credit and one later replacement draw. Older games and optional/Advanced scopes remain separate; nested originating-card coverage remains unfinished.",
|
||||
},
|
||||
{
|
||||
area: 'Player controls' as const,
|
||||
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. 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. Two controlled390px Shrine-role recovery journeys retain the bank3 three-force delivery and one definite answer.",
|
||||
"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. Fresh bare Basic Richese previews now enable the bounded reactions with selected native families. Actual persistent Ambassador reaction controls retain Continue, commitment and private multi-selection; optional/Advanced starts keep their existing scope. 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. Two controlled390px Shrine-role recovery journeys retain the bank3 three-force delivery and one definite answer.",
|
||||
},
|
||||
{
|
||||
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. For each native BG cost and printed counter-cost family, all four existing profiles recover only the current fresh card through their private legal action. Native response allowance/denial remains separate; subsequent owner actions exercise the real retained source. No tuning or calibration. Thirty-two controlled legal-policy probes cover both physical Shrine-role events in both modes; no calibration.",
|
||||
"All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration. All four existing profiles recover the original Guild stop cost via legal private actions, and the native prevention then executes once. No new preference, tuning or calibration. For each native BG cost and printed counter-cost family, all four existing profiles recover only the current fresh card through their private legal action. Native response allowance/denial remains separate; subsequent owner actions exercise the real retained source. No tuning or calibration. Thirty-two controlled legal-policy probes cover both physical Shrine-role events in both modes; no calibration. Four existing profiles also complete actual public Basic BG-copy CHOAM Ambassador reactions through twenty legal proposals; no new strategy or calibration.",
|
||||
},
|
||||
{
|
||||
area: 'Documentation' as const,
|
||||
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. 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. Shrine composition is documented separately from native BG conversion and from original Discovery acquisition evidence.",
|
||||
"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. Shrine composition is documented separately from native BG conversion and from original Discovery acquisition evidence. Fresh public Basic admission, original-action Ambassador sources, clean shipment/movement boundaries, controlled full-hand/own-card tests and unfinished nested/module scope are now explicit.",
|
||||
},
|
||||
{
|
||||
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. 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. Eight new Shrine cases pass in the57-case affected union, with eight controlled runtime paths and two actual controlled390px journeys; no natural draw/entry, recovery or deployed acceptance.",
|
||||
"Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. Earlier eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance. Earlier sixteen-file224/224 passes with13 new behavior cases. Twenty-six runtime/policy paths pass:8 original-action Guild paths including4 profiles,18 explicitly controlled native-effect paths. Four actual390px journeys pass (2 original/2 controlled). Existing income/refund consumers use actual neutral passes. No worker/D1/saved/deployed or complete-mode claim. Earlier nineteen-file238/238 passes with8 new cases. Twenty-six original-action runtime/policy paths and four actual original-action390px journeys pass. One controlled acquisition/phase probe uses a REAL subsequent CHOAM rediscard and Guild stop without force/wallet staging or duplicate refund. Separate2 controlled Kull parent probes and3 repaired focused cases pass. No worker/D1/saved/deployed certification. Eight new Shrine cases pass in the57-case affected union, with eight controlled runtime paths and two actual controlled390px journeys; no natural draw/entry, recovery or deployed acceptance. Fresh Basic/Ambassador evidence passes twelve-file142/142, types/lint/build, ten original-action handler/table-rendering programmes and four existing profiles with twenty legal proposals. Public setup, actual one-spice Semuta auction, card purchase, placement/shipment and two actual ordinary movement entries retain35 main plus10 cache. Only unopened positions and original scalar/packet lotteries are selected. Full-hand/own-card edges separately stage conserved card transfers. Stale battle acknowledgements/module-rejection expectations and fixture types were repaired; representative old failures reproduced against the previous engine. Framework aliases/CSS stub; no browser, visual, saved-room or deployed acceptance. Wider nested/provenance scope and complete modes remain open.",
|
||||
evidence: [
|
||||
'tests/basic-semuta-ambassador.test.ts',
|
||||
'tests/fixture-basic-semuta-ambassador.ts',
|
||||
'tests/semuta-engine.test.ts',
|
||||
'tests/semuta-recovery.test.ts',
|
||||
'tests/bot-semuta.test.ts',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../game/engine';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import { createLeaderSkills } from '../game/leader-skills';
|
||||
@@ -54,7 +55,7 @@ function completedSetup(initial: Game, difficulty: Difficulty = 'Easy') {
|
||||
assert.equal(game.setupStage, undefined);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(card => card.id).sort();
|
||||
assert.deepEqual(cards, baseDeck().map(card => card.id).sort());
|
||||
assert.deepEqual(cards, [...baseDeck(), ...(initial.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort());
|
||||
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])];
|
||||
assert.equal(new Set(traitors).size, traitors.length);
|
||||
assert.deepEqual(traitors.sort(), game.players.flatMap(p => p.leaders.map(leader => leader.id)).sort());
|
||||
@@ -121,13 +122,20 @@ void test('only the host can select a boolean rules mode, changes clear human re
|
||||
completedSetup(act(ready(game), game.host, { type: 'start' }));
|
||||
});
|
||||
|
||||
void test('unsupported independent cards and Discovery reject the whole start without silently dropping a module', () => {
|
||||
void test('supported independent cards and Discovery preserve both selected modules through public Basic setup', () => {
|
||||
let game = act(lobby(['atreides', 'emperor']), 'atreides',
|
||||
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
game = act(game, game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
|
||||
game = ready(game);
|
||||
reject(game, game.host, { type: 'start' });
|
||||
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length));
|
||||
const before = structuredClone(game), started = act(game, game.host, { type: 'start' });
|
||||
assert.deepEqual(game, before); assert.equal(started.status, 'setup');
|
||||
assert.equal(started.advanced, false); assert.equal(started.ecazTreachery, true);
|
||||
assert.equal(started.discoveryEnabled, true); assert.ok(started.discoveries);
|
||||
assert.ok(started.spiceDeck.some(card => 'discovery' in card && card.discovery));
|
||||
const cards = [...started.deck, ...started.players.flatMap(p => p.hand)];
|
||||
for (const card of ecazTreacheryCards()) assert.ok(cards.some(held => held.id === card.id));
|
||||
assert.equal(started.semutaPreview, undefined);
|
||||
completedSetup(started);
|
||||
});
|
||||
|
||||
void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { faction, type FactionId } from '../game/catalog';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createBasicSemutaAmbassadorFixture, actBasicSemutaAmbassador as act,
|
||||
advanceBasicSemutaAmbassador as advance, basicSemutaAmbassadorPlayer as player,
|
||||
basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorBalances as balances } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
function lobby(roster: FactionId[], advanced = false) {
|
||||
const expansions = [...new Set(roster.map(id => faction(id).expansion).filter(id => id !== 'base'))];
|
||||
let game = createGame('BASICSEMUTAENTRY', newPlayer(roster[0], roster[0], roster[0]), advanced, expansions);
|
||||
for (const id of roster.slice(1)) joinGame(game, newPlayer(id, id, id));
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
return game;
|
||||
}
|
||||
void test('fresh public bare Basic Richese starts initialize Semuta before the original setup', () => {
|
||||
for (const roster of [['richese', 'guild'], ['ecaz', 'richese', 'choam', 'guild'],
|
||||
['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']] as FactionId[][]) {
|
||||
const source = lobby(roster), before = structuredClone(source);
|
||||
const started = applyAction(source, source.host, { type: 'start' });
|
||||
assert.equal(started.semutaPreview, true); assert.equal(started.status, 'setup');
|
||||
assert.equal(started.advanced, false); assert.equal(started.richeseCache?.length, 10);
|
||||
assert.ok(started.players.every(p => p.hand.length === 0));
|
||||
assert.equal(viewGame(started, source.host).semutaPreview, true);
|
||||
assert.deepEqual(source, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('automatic Semuta initialization does not spread to Advanced, optional-module or non-Richese starts', () => {
|
||||
const advanced = lobby(['richese', 'guild'], true);
|
||||
const optional = lobby(['richese', 'guild']); optional.lobbyOptions = { leaderSkills: true };
|
||||
for (const source of [advanced, optional, lobby(['ecaz', 'choam', 'guild'])]) {
|
||||
const started = applyAction(source, source.host, source.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
assert.equal(started.semutaPreview, undefined);
|
||||
}
|
||||
const dirty = lobby(['richese', 'guild']); dirty.semutaPreview = true;
|
||||
assert.throws(() => applyAction(dirty, dirty.host, { type: 'start' }));
|
||||
});
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function pass(game: Game) {
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
function claim(game: Game, actor: string, target: string) {
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
game = act(game, actor, { type: 'semutaCommit', event });
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, actor, { type: 'semutaSelect', event, card: target });
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const effect of ['choam', 'ixians'] as const) for (const copied of [false, true]) {
|
||||
for (const claims of [false, true]) void test(
|
||||
`public Basic ${copied ? 'BG-copy' : 'native'} ${effect} discards ${claims ? 'claim' : 'pass'} before exactly one original continuation`, () => {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect, copied });
|
||||
assert.equal(f.game.pendingAmbassador!.arrivalCause, 'shipment');
|
||||
const source = structuredClone(f.game), sourceBalances = balances(source);
|
||||
const pending = act(source, f.discardStep.actor, f.discardStep.action);
|
||||
assert.deepEqual(source, f.game);
|
||||
const receipt = pending.pendingTreacheryDiscard!;
|
||||
assert.equal(receipt?.reaction?.stage, 'offer'); assert.equal(receipt.continuation.kind, 'ambassador');
|
||||
assert.equal(receipt.batch.cause, `ambassador:${effect}`);
|
||||
assert.deepEqual(receipt.batch.entries.map(e => e.card.id), f.chosenCards);
|
||||
assert.ok(receipt.batch.entries.every(e => !e.publicFace && e.discardedBy === f.beneficiary));
|
||||
assert.equal(pending.pendingAmbassador, null); assert.equal(pending.active, f.entrant);
|
||||
const payout = effect === 'choam' ? 6 : 0;
|
||||
assert.equal(player(pending).spice, sourceBalances[f.beneficiary] + payout);
|
||||
assert.deepEqual(pending.deck, source.deck, 'Ix replacement waits for this reaction');
|
||||
for (const p of pending.players) {
|
||||
const reaction = viewGame(pending, p.id).semutaReaction!;
|
||||
assert.deepEqual(reaction.candidates, []); assert.equal(reaction.canCommit, p.id === f.holder);
|
||||
}
|
||||
rejected(pending, f.holder, { type: 'semutaSelect', event: receipt.batch.event, card: f.chosenCards[0] });
|
||||
rejected(pending, f.entrant, f.entrantContinuation.action);
|
||||
rejected(pending, f.beneficiary, f.discardStep.action);
|
||||
let resumed: Game;
|
||||
if (claims && effect === 'choam') {
|
||||
const committed = act(pending, f.holder, { type: 'semutaCommit', event: receipt.batch.event });
|
||||
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.deepEqual(viewGame(committed, f.holder).semutaReaction!.candidates.map(c => c.id), f.chosenCards);
|
||||
for (const p of committed.players.filter(p => p.id !== f.holder))
|
||||
assert.deepEqual(viewGame(committed, p.id).semutaReaction!.candidates, []);
|
||||
rejected(committed, f.beneficiary, { type: 'semutaSelect', event: receipt.batch.event, card: f.chosenCards[0] });
|
||||
rejected(committed, f.holder, { type: 'semutaPass', event: receipt.batch.event });
|
||||
resumed = act(committed, f.holder, { type: 'semutaSelect', event: receipt.batch.event, card: f.chosenCards[0] });
|
||||
} else resumed = claims ? claim(pending, f.holder, f.chosenCards[0]) : pass(pending);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null); assert.equal(resumed.pendingAmbassador, null);
|
||||
assert.equal(resumed.active, f.entrant); assert.equal(resumed.phase, 5);
|
||||
assert.equal(player(resumed).spice, sourceBalances[f.beneficiary] + payout);
|
||||
assert.equal(player(resumed, f.entrant).spice, sourceBalances[f.entrant]);
|
||||
assert.equal(player(resumed, f.entrant).forces[f.location], 1);
|
||||
assert.equal(player(resumed, f.holder).hand.some(c => c.id === f.chosenCards[0]), claims);
|
||||
assert.equal(resumed.discard.filter(c => c.id === f.semuta).length, Number(claims));
|
||||
assert.equal(resumed.discard.some(c => c.id === f.chosenCards[0]), !claims);
|
||||
if (effect === 'ixians') {
|
||||
assert.ok(player(resumed).hand.some(c => c.id === f.preReplacementDeckTop.id));
|
||||
assert.deepEqual(resumed.deck.map(c => c.id), source.deck.slice(1).map(c => c.id));
|
||||
} else assert.deepEqual(resumed.deck, source.deck);
|
||||
assert.deepEqual(inventory(resumed), f.originalTreachery);
|
||||
rejected(resumed, f.holder, { type: 'semutaCommit', event: receipt.batch.event });
|
||||
resumed = advance(resumed, state => !state.response && !state.phaseOpening && !state.decision);
|
||||
const finished = act(resumed, f.entrant, f.entrantContinuation.action);
|
||||
assert.ok(!finished.movementRemaining?.includes(f.entrant));
|
||||
assert.deepEqual(inventory(finished), f.originalTreachery);
|
||||
});
|
||||
}
|
||||
|
||||
void test('CHOAM may discard nothing without opening an empty Semuta event or crediting spice', () => {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam' });
|
||||
const game = act(f.game, f.beneficiary, { type: 'decision', event: f.event, cards: [] });
|
||||
assert.equal(game.pendingTreacheryDiscard, undefined); assert.equal(game.pendingAmbassador, null);
|
||||
assert.deepEqual(balances(game), f.balances); assert.deepEqual(inventory(game), f.originalTreachery);
|
||||
});
|
||||
|
||||
for (const effect of ['choam', 'ixians'] as const) void test(`actual ordinary movement entry resumes after ${effect} Semuta recovery`, () => {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect, arrival: 'movement' });
|
||||
assert.equal(f.game.pendingAmbassador!.arrivalCause, 'movement');
|
||||
assert.equal(player(f.game, f.entrant).moved, 1);
|
||||
const pending = act(f.game, f.discardStep.actor, f.discardStep.action);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
let game = claim(pending, f.holder, f.chosenCards[0]);
|
||||
assert.equal(game.pendingAmbassador, null); assert.equal(game.active, f.entrant);
|
||||
assert.equal(player(game, f.entrant).moved, 1);
|
||||
assert.equal(player(game, f.entrant).forces[f.location], 1);
|
||||
game = advance(game, state => !state.response && !state.phaseOpening && !state.decision);
|
||||
game = act(game, f.entrant, f.entrantContinuation.action);
|
||||
assert.ok(!game.movementRemaining?.includes(f.entrant));
|
||||
assert.deepEqual(inventory(game), f.originalTreachery);
|
||||
});
|
||||
|
||||
void test('older entries without an arrival witness and nested transports retain the original non-Semuta path', () => {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect: 'ixians' });
|
||||
for (const cause of [undefined, 'guildTransport', 'advisor', 'wormRide'] as const) {
|
||||
const source = structuredClone(f.game); source.pendingAmbassador!.arrivalCause = cause;
|
||||
const game = act(source, f.discardStep.actor, f.discardStep.action);
|
||||
assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingAmbassador, null);
|
||||
assert.ok(player(game).hand.some(c => c.id === f.preReplacementDeckTop.id));
|
||||
assert.deepEqual(balances(game), f.balances); assert.deepEqual(inventory(game), f.originalTreachery);
|
||||
}
|
||||
const old = structuredClone(f.game); delete old.semutaPreview;
|
||||
const resumed = act(old, f.discardStep.actor, f.discardStep.action);
|
||||
assert.equal(resumed.semutaPreview, undefined); assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
});
|
||||
|
||||
void test('a controlled full hand exchanges Semuta atomically without consuming the beneficiary replacement slot', () => {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect: 'ixians', copied: true });
|
||||
const game = structuredClone(f.game), owner = player(game, f.holder);
|
||||
while (owner.hand.length < 4) owner.hand.push(game.deck.pop()!);
|
||||
const pending = act(game, f.discardStep.actor, f.discardStep.action);
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
|
||||
const result = claim(pending, f.holder, f.chosenCards[0]);
|
||||
assert.equal(player(result, f.holder).hand.length, 4);
|
||||
assert.ok(player(result).hand.some(c => c.id === f.preReplacementDeckTop.id));
|
||||
assert.deepEqual(inventory(result), f.originalTreachery);
|
||||
});
|
||||
|
||||
void test('a controlled beneficiary holding Semuta cannot retrieve its own fresh private discard', () => {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam' }), game = structuredClone(f.game);
|
||||
const holder = player(game, f.holder), semuta = holder.hand.splice(holder.hand.findIndex(c => c.id === f.semuta), 1)[0];
|
||||
player(game).hand.push(semuta);
|
||||
const pending = act(game, f.discardStep.actor, f.discardStep.action), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(viewGame(pending, f.beneficiary).semutaReaction!.canCommit, false);
|
||||
rejected(pending, f.beneficiary, { type: 'semutaCommit', event });
|
||||
const result = pass(pending); assert.ok(player(result).hand.some(c => c.id === f.semuta));
|
||||
assert.deepEqual(inventory(result), f.originalTreachery);
|
||||
});
|
||||
|
||||
void test('all four existing profiles legally complete actual Ambassador Semuta reactions', () => {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam', copied: true });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = act(f.game, f.discardStep.actor, f.discardStep.action), count = 0;
|
||||
while (game.pendingTreacheryDiscard && count++ < 12) {
|
||||
const reaction = game.pendingTreacheryDiscard.reaction!;
|
||||
const actor = reaction.stage === 'select' ? reaction.player :
|
||||
game.players.find(p => !reaction.passed.includes(p.id))!.id;
|
||||
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action); game = act(game, actor, action);
|
||||
}
|
||||
assert.ok(count < 12); assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(game.pendingAmbassador, null); assert.equal(game.active, f.entrant);
|
||||
assert.deepEqual(inventory(game), f.originalTreachery);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type BasicSemutaAmbassadorEffect = 'choam' | 'ixians';
|
||||
export type BasicSemutaAmbassadorStep = { actor: string; action: Action };
|
||||
export type BasicSemutaAmbassadorTransition = { before: Game; step: BasicSemutaAmbassadorStep; after: Game };
|
||||
export const basicSemutaAmbassadorPlayer = (game: Game, actor = 'ecaz') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const basicSemutaAmbassadorClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export const basicSemutaAmbassadorBalances = (game: Game) => Object.fromEntries(
|
||||
game.players.map(player => [player.id, player.spice]),
|
||||
);
|
||||
export const basicSemutaAmbassadorInventory = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(player => player.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Every original physical Treachery card has one current slot');
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Only original scalar lotteries are selected. Nine supplied rolls are scoped
|
||||
* to native packet creation; UUID and all other byte entropy remains native. */
|
||||
function withOriginalScalarLottery<T>(run: () => T, packet: readonly number[] = []): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < packet.length ? packet[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const result = run(); assert.equal(cursor, packet.length, 'The original packet consumed its nine selected draws');
|
||||
return result;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actBasicSemutaAmbassador(game: Game, actor: string, action: Action,
|
||||
actions: BasicSemutaAmbassadorStep[] = []): Game {
|
||||
const next = withOriginalScalarLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
|
||||
/** The source owns its auction, placement and effect selections. Continuations
|
||||
* close actual native windows without playing Semuta or choosing a card grant. */
|
||||
export function nextBasicSemutaAmbassadorStep(game: Game): BasicSemutaAmbassadorStep | null {
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
if (reaction?.stage !== 'offer') return null;
|
||||
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
if (game.decision?.kind === 'ecazAmbassador' || game.decision?.kind === 'richeseCache') return null;
|
||||
if (game.decision?.kind === 'ecazPlacement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' &&
|
||||
basicSemutaAmbassadorPlayer(game).reserves === 20)
|
||||
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceBasicSemutaAmbassador(game: Game, until: (state: Game) => boolean,
|
||||
actions: BasicSemutaAmbassadorStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Basic programme ended before its next control');
|
||||
const next = nextBasicSemutaAmbassadorStep(game);
|
||||
assert.ok(next, `Supply the original auction or Ambassador choice before advancing: ${JSON.stringify({
|
||||
status: game.status, turn: game.turn, phase: game.phase, active: game.active, decision: game.decision,
|
||||
entry: game.pendingAmbassador ? { stage: game.pendingAmbassador.stage,
|
||||
effect: game.pendingAmbassador.effect, entrant: game.pendingAmbassador.entrant } : null,
|
||||
discard: game.pendingTreacheryDiscard?.reaction?.stage,
|
||||
})}`);
|
||||
game = actBasicSemutaAmbassador(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Basic Semuta/Ambassador programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Select the original ten-token Fisher–Yates packet, never replace its state.
|
||||
* A BG source deliberately excludes the eventual copied effect from its cohort. */
|
||||
function originalPacket(effect: BasicSemutaAmbassadorEffect, copied: boolean) {
|
||||
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = [copied ? 'beneGesserit' : effect,
|
||||
'atreides', 'emperor', 'fremen', 'harkonnen'];
|
||||
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))];
|
||||
const current = [...pool], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); assert.equal(rolls.length, 9);
|
||||
return { rolls, supply };
|
||||
}
|
||||
|
||||
/** Fresh public Basic E2/E3 source. Guild really buys Semuta from the original
|
||||
* ten-card Richese cache. Ecaz buys one normal card and pays for one placement;
|
||||
* Guild then pays for an actual entry at that marker. Stop at the native cards
|
||||
* grant BEFORE any discard, payout, replacement draw or Semuta commitment. */
|
||||
export function createBasicSemutaAmbassadorFixture(options: {
|
||||
effect: BasicSemutaAmbassadorEffect; copied?: boolean; arrival?: 'shipment' | 'movement';
|
||||
}) {
|
||||
const effect = options.effect, copied = options.copied ?? false, packet = originalPacket(effect, copied);
|
||||
const arrivalKind = options.arrival ?? 'shipment';
|
||||
let game = createGame('BASICSEMUTAAMBASSADOR', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
|
||||
for (const faction of ['choam', 'richese', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const owner = 'ecaz', beneficiary = owner, holder = 'guild', entrant = holder;
|
||||
const actions: BasicSemutaAmbassadorStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actBasicSemutaAmbassador(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceBasicSemutaAmbassador(game, until, actions); };
|
||||
const transition = (step: BasicSemutaAmbassadorStep, until: (state: Game) => boolean): BasicSemutaAmbassadorTransition => {
|
||||
const before = structuredClone(game); act(step.actor, step.action); reach(until);
|
||||
return { before, step: structuredClone(step), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.semutaPreview, true, 'A fresh public bare Basic Richese start owns the Semuta marker');
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache?.length, 10);
|
||||
assert.ok(game.richeseCache!.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
const originalMainDeck = game.deck.map(card => card.id).sort();
|
||||
const originalCache = game.richeseCache!.map(card => card.id).sort();
|
||||
const originalTreachery = basicSemutaAmbassadorInventory(game);
|
||||
// Four still-undealt starts plus the first still-unopened normal purchase.
|
||||
// Ordinary cards avoid optional card-use controls during the source path.
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 5);
|
||||
assert.equal(ordinary.length, 5);
|
||||
const deal = ordinary.map(card => card.id);
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let packetCreation: BasicSemutaAmbassadorTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 80; count++) {
|
||||
const next = nextBasicSemutaAmbassadorStep(game); assert.ok(next);
|
||||
const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
|
||||
if (initializesForces) {
|
||||
const before = structuredClone(game);
|
||||
game = withOriginalScalarLottery(() => applyAction(game, next.actor, next.action), packet.rolls);
|
||||
actions.push(structuredClone(next));
|
||||
packetCreation = { before, step: structuredClone(next), after: structuredClone(game) };
|
||||
} else act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation);
|
||||
const afterSetup = structuredClone(game);
|
||||
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === (copied ? 'beneGesserit' : effect))!;
|
||||
assert.equal(token.zone, 'supply');
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.effect === effect)!.zone, copied ? 'pool' : 'supply');
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id =>
|
||||
game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
|
||||
assert.deepEqual(basicSemutaAmbassadorPlayer(game).hand.map(card => card.id), [deal[0]]);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
const beforeCacheOffer = structuredClone(game), acquisitionStart = actions.length;
|
||||
const cacheOffer: BasicSemutaAmbassadorStep = { actor: 'richese', action: { type: 'decision',
|
||||
event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
act(cacheOffer.actor, cacheOffer.action);
|
||||
reach(state => basicSemutaAmbassadorClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
const cacheAuction = structuredClone(game);
|
||||
const cacheBidSteps: BasicSemutaAmbassadorStep[] = [];
|
||||
let semutaBid: BasicSemutaAmbassadorTransition | null = null;
|
||||
for (let count = 0; !basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot && !lot.outcome && lot.active);
|
||||
const bid: BasicSemutaAmbassadorStep = { actor: lot.active, action: { type: 'richeseBid', event: lot.event,
|
||||
amount: lot.active === holder ? 1 : null, allyPayment: 0 } };
|
||||
cacheBidSteps.push(structuredClone(bid));
|
||||
const result = transition(bid, state => basicSemutaAmbassadorClean(state));
|
||||
if (bid.actor === holder) semutaBid = result;
|
||||
}
|
||||
assert.ok(semutaBid); assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(game.richeseCache!.length, 9);
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game, holder).spice,
|
||||
basicSemutaAmbassadorPlayer(beforeCacheOffer, holder).spice - 1);
|
||||
const acquisition = { before: beforeCacheOffer, steps: structuredClone(actions.slice(acquisitionStart)), after: structuredClone(game) };
|
||||
|
||||
reach(state => state.phase === 3 && basicSemutaAmbassadorClean(state) && state.auction?.active === owner);
|
||||
const normalPurchase = transition({ actor: owner, action: { type: 'bid', amount: 1, allyPayment: 0 } },
|
||||
state => basicSemutaAmbassadorPlayer(state, owner).hand.length === 2 && basicSemutaAmbassadorClean(state));
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game).spice, basicSemutaAmbassadorPlayer(normalPurchase.before).spice - 1);
|
||||
assert.deepEqual(basicSemutaAmbassadorPlayer(game).hand.map(card => card.id), [deal[0], deal[4]]);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const territory = 'habbanya_ridge_sietch', sector = 17;
|
||||
const placement = transition({ actor: owner, action: { type: 'decision', token: token.id, territory } },
|
||||
state => !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game).spice, basicSemutaAmbassadorPlayer(placement.before).spice - 1);
|
||||
act(owner, { type: 'decision', decline: true });
|
||||
reach(state => state.phase === 5 && state.active === entrant && basicSemutaAmbassadorClean(state));
|
||||
const beforeArrival = structuredClone(game);
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).shipped, false);
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).ally, null);
|
||||
assert.ok(game.players.every(player => !Object.keys(player.forces).some(location => location.startsWith(`${territory}:`))));
|
||||
const shipmentTerritory = arrivalKind === 'movement' ? 'habbanya_ridge_flat' : territory;
|
||||
const shipment = transition({ actor: entrant, action: { type: 'ship', amount: 1, territory: shipmentTerritory, sector, elite: 0, allyPayment: 0 } },
|
||||
state => arrivalKind === 'shipment'
|
||||
? !state.response && state.pendingAmbassador?.stage === 'offer'
|
||||
: basicSemutaAmbassadorClean(state));
|
||||
const arrival = arrivalKind === 'shipment' ? shipment : transition({ actor: entrant, action: {
|
||||
type: 'move', from: `${shipmentTerritory}:${sector}`, amount: 1, territory, sector,
|
||||
} }, state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).spice,
|
||||
basicSemutaAmbassadorPlayer(beforeArrival, entrant).spice - 1);
|
||||
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
|
||||
assert.equal(game.pendingAmbassador!.token, token.id);
|
||||
const triggerStep: BasicSemutaAmbassadorStep = { actor: owner,
|
||||
action: { type: 'decision', event, trigger: true, beneficiary, choice: 'effect' } };
|
||||
act(triggerStep.actor, triggerStep.action);
|
||||
let beforeCopy: Game | null = null, copyStep: BasicSemutaAmbassadorStep | null = null;
|
||||
if (copied) {
|
||||
assert.equal(game.pendingAmbassador?.stage, 'copy');
|
||||
assert.ok(game.pendingAmbassador!.copyChoices.includes(effect)); beforeCopy = structuredClone(game);
|
||||
copyStep = { actor: beneficiary, action: { type: 'decision', event, effect } };
|
||||
act(copyStep.actor, copyStep.action);
|
||||
}
|
||||
assert.equal(game.pendingAmbassador?.stage, 'cards'); assert.equal(game.pendingAmbassador!.effect, effect);
|
||||
assert.equal(game.pendingAmbassador!.beneficiary, beneficiary); assert.equal(game.pendingAmbassador!.entrant, entrant);
|
||||
assert.equal(game.decision?.player, beneficiary); assert.equal(game.active, entrant);
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.deepEqual(basicSemutaAmbassadorInventory(game), originalTreachery);
|
||||
const currentGrant = structuredClone(game), eligibleChosenIds = basicSemutaAmbassadorPlayer(game, beneficiary).hand
|
||||
.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
|
||||
assert.equal(eligibleChosenIds.length, effect === 'choam' ? 2 : 1);
|
||||
const preReplacementDeckTop = structuredClone(game.deck[0]); assert.ok(preReplacementDeckTop);
|
||||
const discardStep: BasicSemutaAmbassadorStep = { actor: beneficiary, action: { type: 'decision', event, cards: eligibleChosenIds } };
|
||||
return { game, lobby, setup, beforeStartingDeal, afterSetup, packetCreation, packet, deal, dealOrder,
|
||||
spiceOrder, originalMainDeck, originalCache, originalTreachery, beforeCacheOffer, cacheOffer,
|
||||
cacheAuction, cacheBidSteps, semutaBid, acquisition, normalPurchase, placement, beforeArrival, shipment, arrival, arrivalKind, offer,
|
||||
triggerStep, beforeCopy, copyStep, currentGrant, ownerView: viewGame(game, owner), holderView: viewGame(game, holder),
|
||||
entrantView: viewGame(game, entrant), effect, copied, advanced: false as const,
|
||||
owner, beneficiary, holder, entrant, token: token.id, semuta: SEMUTA_DRUG_ID, event, territory, sector,
|
||||
location: `${territory}:${sector}`, eligibleChosenIds, chosenCards: eligibleChosenIds, discardStep,
|
||||
preReplacementDeckTop, balances: basicSemutaAmbassadorBalances(game),
|
||||
entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as BasicSemutaAmbassadorStep,
|
||||
actions, sourceActionCount: actions.length,
|
||||
staging: ['Only five unopened original main-deck positions (four starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot.'] };
|
||||
}
|
||||
export type BasicSemutaAmbassadorFixture = ReturnType<typeof createBasicSemutaAmbassadorFixture>;
|
||||
@@ -12,6 +12,17 @@ import { newRevivalRules } from '../game/revival';
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
/** These discard tests begin after the participants acknowledge the actual
|
||||
* revealed result; that native window precedes fresh cleanup reactions. */
|
||||
function finishBattleOutcome(game: Game): Game {
|
||||
while (game.battle?.outcomeWindow) {
|
||||
const window = game.battle.outcomeWindow;
|
||||
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id));
|
||||
assert.ok(actor); game = applyAction(game, actor, { type: 'battleOutcomeReady', event: window.event });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
function take(g: Game, id: string) {
|
||||
const zones = [g.deck, g.discard, g.richeseCache!, ...g.players.map(p => p.hand)];
|
||||
for (const zone of zones) {
|
||||
@@ -88,6 +99,7 @@ for (const holder of ['a', 'r'] as const) void test(
|
||||
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: player(state, 'e').leaders.find(isAuditorLeader)!.id, defense: defense.id });
|
||||
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
|
||||
state = finishBattleOutcome(state);
|
||||
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const pass = (game: Game) => {
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
@@ -165,6 +177,7 @@ void test('Semuta preserves quoted CHOAM battle income through original allowanc
|
||||
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: player(state, 'e').leaders.find(l => !isAuditorLeader(l))!.id, defense: defense.id });
|
||||
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
|
||||
state = finishBattleOutcome(state);
|
||||
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(state.pendingChoamBattleIncome, { owner: 'e', amount: 1 });
|
||||
assert.equal(player(state, 'e').spice, beforeIncome);
|
||||
@@ -262,6 +275,7 @@ void test('Semuta settles mandatory and winner discards before one native Face D
|
||||
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: player(state, 'e').leaders[0].id, defense: defense.id });
|
||||
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
|
||||
state = finishBattleOutcome(state);
|
||||
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(state.pendingFaceDance?.winner, 'a');
|
||||
const pass = (game: Game) => {
|
||||
@@ -1659,6 +1673,7 @@ void test('clean winner-selected battle cards allow one committed Semuta choice
|
||||
leader: player(battle, 'e').leaders[0].id, defense: defense.id });
|
||||
battle = applyAction(battle, 'a', { type: 'traitorCall', call: false });
|
||||
battle = applyAction(battle, 'e', { type: 'traitorCall', call: false });
|
||||
battle = finishBattleOutcome(battle);
|
||||
assert.equal(battle.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
|
||||
assert.equal(battle.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(battle.pendingTreacheryDiscard?.batch.entries.map(entry => [
|
||||
@@ -1770,6 +1785,7 @@ void test('Semuta settles mandatory and winner discards before the original Hark
|
||||
state = applyAction(state, 'a', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: player(state, 'a').leaders[0].id, defense: defense.id });
|
||||
for (const id of ['e', 'a']) state = applyAction(state, id, { type: 'traitorCall', call: false });
|
||||
state = finishBattleOutcome(state);
|
||||
assert.equal(state.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
|
||||
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(state.pendingCapture, { player: 'e', loser: 'a', territory: 'arrakeen' });
|
||||
@@ -1857,7 +1873,7 @@ void test('a mandatory winner Hero is reserved through the loser offer and claim
|
||||
battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: player(battle, 'e').leaders[0].id, defense: defense.id });
|
||||
battle = applyAction(battle, 'a', { type: 'traitorCall', call: false });
|
||||
let pending = applyAction(battle, 'e', { type: 'traitorCall', call: false });
|
||||
let pending = finishBattleOutcome(applyAction(battle, 'e', { type: 'traitorCall', call: false }));
|
||||
assert.equal(player(pending, 'a').hand.some(card => card.id === hero.id), true);
|
||||
assert.equal(pending.discard.some(card => card.id === hero.id), false);
|
||||
const loserEvent = pending.pendingTreacheryDiscard!.batch.event;
|
||||
@@ -1929,7 +1945,7 @@ void test('a mutual-traitor battle offers one mixed-owner mandatory batch with o
|
||||
battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: player(battle, 'e').leaders[0].id, defense: snooper.id });
|
||||
battle = applyAction(battle, 'a', { type: 'traitorCall', call: true });
|
||||
const pending = applyAction(battle, 'e', { type: 'traitorCall', call: true });
|
||||
const pending = finishBattleOutcome(applyAction(battle, 'e', { type: 'traitorCall', call: true }));
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => [
|
||||
|
||||
Reference in new issue
Block a user