Support Basic Richese Kull and Semuta preview
This commit is contained in:
1 parent
6d50829c0c
commit
a07c6d8e50
13 files changed
+480
-14
No files matched your search
@@ -2210,10 +2210,11 @@ export function GameTable({
|
||||
Kull Wahad · Basic development preview
|
||||
</label>
|
||||
{g.lobbyModules?.kullPreview && <p className="notice">
|
||||
Requires Basic CHOAM with classic factions or Ixian, Tleilaxu, Ecaz or Moritani factions, the CHOAM and Ix decks plus each faction’s deck, and no other optional modules.
|
||||
Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules.
|
||||
This preview does not support structured Truthtrance questions about bids, alliances, Revival,
|
||||
or combined turn actions, and it defers unfunded non-CHOAM Karama-dependent bids. It does not represent complete
|
||||
CHOAM rules. Every player must ready again after changing it.
|
||||
When Richese is seated, structured bribe commitments are also unavailable, and some Kull reactions may be unavailable in complex response windows.
|
||||
</p>}
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
|
||||
@@ -2,6 +2,42 @@
|
||||
|
||||
Source audit: 2026-09-06; opt-in preview contract updated 30 September 2026. This is an engineering and product-policy record, not a rules-completion claim. The source audit in [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md) remains applicable. The user-approved counter and BG choices supersede the original proposal; publisher-source uncertainty and full expansion gates remain.
|
||||
|
||||
## Basic Richese and Kull/Semuta — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The fresh Basic printed-Kull opt-in includes
|
||||
include native Richese, retaining its ordinary cache and Semuta setup. An
|
||||
eligible successful Kull discard and an eligible distinct Karama counter
|
||||
discard each pass through the existing neutral Semuta claim-or-pass
|
||||
opportunity before the exact suspended Kull continuation resumes. The original
|
||||
attempted card, response, actor and costs remain bound to that continuation;
|
||||
claiming a discard changes its custody, not the outcome of Kull or its counter.
|
||||
|
||||
The [publisher's Basic Kull rule, printed page 7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7)
|
||||
and the existing [Semuta source and interpretation](SEMUTA_SOURCE_UPDATE_20260907.md)
|
||||
provide the respective printed effects. Their nested online timing and the
|
||||
distinct-card counter remain provisional implementation policies, not a new
|
||||
publisher ruling. The user-deferred non-CHOAM unfunded-overbid fence remains.
|
||||
|
||||
Complex nested Kull parents are not offered as Kull costs in this preview,
|
||||
independently of hidden Semuta ownership. Structured bribe commitments in the Richese
|
||||
Semuta envelope remain outside this preview, as do other unconnected
|
||||
interactions and optional modules. Their boundaries must be visible before
|
||||
starting. This work does not certify complete faction rules, modes, recovery,
|
||||
browser or deployed behavior, or open publication gates.
|
||||
|
||||
The native fixture acquires Semuta through Richese's real cache auction before
|
||||
testing the public discard and its continuation. A separate controlled
|
||||
Ornithopter supplement checks that the nested-parent gate does not depend on
|
||||
the private Semuta hand. An older Semuta Cunning fixture was also updated to
|
||||
follow the actual Advanced Charity and Revival queues; its prior failure was
|
||||
reproduced on the preceding commit.
|
||||
Frozen changed-path proof passed 132/132 tests in 14 files, type/lint and the
|
||||
production build. [Native runtime tests](../tests/kull-richese-semuta-runtime.test.ts)
|
||||
cover both discards and [the Worthless parent](../tests/kull-richese-semuta-worthless-parent.test.ts)
|
||||
uses a documented controlled physical-card supplement. The nested-window gate
|
||||
test uses a controlled Ornithopter; broader browser and deployed behavior remain
|
||||
unverified.
|
||||
|
||||
## Basic native Ecaz and Moritani — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The fresh Basic printed-Kull opt-in admits Ecaz and
|
||||
|
||||
@@ -20,6 +20,16 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Basic Richese and Kull/Semuta — 27 October. Prototyped / Partial.**
|
||||
The printed-Kull opt-in includes native Richese. In supported clean parents,
|
||||
successful Kull and a distinct counter open the public Semuta discard reaction
|
||||
before their original continuation resumes. Complex nested Kull parents and
|
||||
structured bribe questions with Richese remain unavailable, visibly disclosed
|
||||
as unfinished; broader rules and deployed acceptance remain open.
|
||||
[Scope](CHOAM_KULL_DESIGN.md#basic-richese-and-kullsemuta--27-october-2026).
|
||||
Frozen changed-path checks passed 132/132 tests across 14 files, type/lint and
|
||||
the production build; browser and deployed acceptance remain open.
|
||||
|
||||
**Printed Kull with native Ecaz/Moritani and movement forecast — 27 October. Prototyped / Partial.**
|
||||
The Basic Kull opt-in includes Ecaz and Moritani with their selected deck,
|
||||
CHOAM and Ix; existing supported bribe questions remain available. A detached
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Basic Richese and Kull/Semuta
|
||||
|
||||
The printed-Kull opt-in includes native Richese and its existing cache setup.
|
||||
In supported clean parent windows, a successful Kull or distinct counter's
|
||||
public discard offers Semuta before the original continuation resumes. Complex
|
||||
parent windows and structured bribe questions with Richese remain unavailable;
|
||||
full modes, wider interactions and deployed acceptance remain open.
|
||||
The frozen batch passed 132/132 tests in 14 files, type/lint and the production
|
||||
build. The native fixture buys Semuta through the real Richese cache; separate
|
||||
controlled supplements cover an earlier Worthless parent and an unsupported
|
||||
Ornithopter parent. An older Advanced fixture was updated for its native queues.
|
||||
[Contract](CHOAM_KULL_DESIGN.md#basic-richese-and-kullsemuta--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Native Ecaz/Moritani Kull and movement forecast
|
||||
|
||||
The Basic printed-Kull opt-in adds Ecaz and Moritani with all three selected
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic Richese and Kull/Semuta — 27 October 2026
|
||||
|
||||
Provisionally offer Semuta after the public discard of a successful printed
|
||||
Kull or a distinct Karama counter, before the existing interrupted parent
|
||||
continues. Nested online timing remains a software interpretation; unsupported
|
||||
complex parents are gated independently of hidden hands.
|
||||
[Contract](CHOAM_KULL_DESIGN.md#basic-richese-and-kullsemuta--27-october-2026).
|
||||
|
||||
## Basic printed Kull with native Ecaz and Moritani — 27 October 2026
|
||||
|
||||
Extend the existing provisional printed-Kull protocol to Ecaz and Moritani
|
||||
|
||||
+115
-6
@@ -1720,6 +1720,16 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'kullSuccessDiscard' | 'kullCounterDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
frame: NonNullable<Game['pendingKull']>;
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
declaration?: NonNullable<Game['pendingChoamWorthless']>;
|
||||
use?: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'bgKaramaCostDiscard';
|
||||
player: string;
|
||||
@@ -6488,6 +6498,32 @@ type KaramaResponseDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' | 'karamaMarketDiscard' | 'karamaFremenGrantDiscard' | 'karamaAdvisorFlipDiscard' }
|
||||
>;
|
||||
type KullSemutaDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'kullSuccessDiscard' | 'kullCounterDiscard' }
|
||||
>;
|
||||
/** These native parents can be suspended without replaying another physical
|
||||
* transaction. More deeply nested response families retain their own gates. */
|
||||
function kullSemutaParentSupported(g: Game): boolean {
|
||||
return !g.truthtrance && !g.battle && !g.pendingRicheseGift && !g.pendingNullentropy &&
|
||||
!g.pendingAmbassador && !g.pendingIxAlly && !g.pendingRevival && !g.pendingShipment &&
|
||||
!g.pendingGuildTransport && !g.pendingHomeworldShipment && !g.pendingTerrorEntry &&
|
||||
!g.pendingEcazPlacement && !g.pendingMoritaniPlacement &&
|
||||
!g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingExchange &&
|
||||
!g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola && !g.currentAuctionSale &&
|
||||
!g.choamMarket && !g.richeseAuction && !g.pendingFremenMove && !g.pendingIxMove &&
|
||||
!g.pendingMobileMove && !g.pendingChoamMove && !g.ornithopter && !g.ecologicalStorm &&
|
||||
!g.summonedWorm && !g.wormRides.length;
|
||||
}
|
||||
function kullSemutaDiscardSignature(g: Game, c: KullSemutaDiscardContinuation): string {
|
||||
return JSON.stringify({ kind: c.kind, player: c.player, card: c.card, frame: c.frame,
|
||||
resume: c.resume, declaration: c.declaration, use: c.use, turn: g.turn, phase: g.phase,
|
||||
parent: nullentropyParentSignature(g, c.resume, c.frame.event),
|
||||
active: g.active, order: g.order, auction: g.auction, richeseAuction: g.richeseAuction,
|
||||
restrictions: g.kullRestrictions, discard: g.discard.map(card => card.id),
|
||||
players: g.players.map(player => ({id:player.id, hand:player.hand.map(card => card.id),
|
||||
spice:player.spice, reserves:player.reserves, forces:player.forces, tanks:player.tanks})) });
|
||||
}
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
const worthless = c.kind === 'karamaChoamWorthlessDiscard';
|
||||
@@ -7462,7 +7498,31 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The committed Semuta claim lost its private fresh candidates.');
|
||||
} else throw new RuleError('Unknown saved Semuta reaction stage.');
|
||||
}
|
||||
if (continuation?.kind === 'nullentropyDiscard') {
|
||||
if (continuation?.kind === 'kullSuccessDiscard' || continuation?.kind === 'kullCounterDiscard') {
|
||||
const c = continuation, entry = batch.entries[0], frame = c.frame;
|
||||
requireRule(g.kullPreview === true && !g.pendingKull &&
|
||||
(c.kind === 'kullSuccessDiscard'
|
||||
? JSON.stringify(g.pendingChoamWorthless ?? null) === JSON.stringify(frame.worthless ?? null)
|
||||
: !g.pendingChoamWorthless) &&
|
||||
c.stateSignature === kullSemutaDiscardSignature(g, c) &&
|
||||
frame.stage === 'counter' && frame.turn === g.turn && frame.phase === g.phase &&
|
||||
frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]) &&
|
||||
frame.signature === kullSignature(g, frame) &&
|
||||
getPlayer(g, frame.owner).hand.some(card => card.id === frame.card) &&
|
||||
batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player &&
|
||||
entry.card.id === c.card && canonicalKullCard(entry.card) &&
|
||||
(c.kind === 'kullSuccessDiscard'
|
||||
? c.player === frame.player && frame.selection?.source === 'printed' &&
|
||||
frame.selection.card === c.card && !c.declaration && !c.use &&
|
||||
!!g.kullRestrictions?.some(row => row.player === frame.owner && row.turn === g.turn && row.phase === g.phase)
|
||||
: c.card !== frame.card && entry.card.effect === 'karama' && !!c.declaration &&
|
||||
c.declaration.effect === 'kull' && c.declaration.owner === frame.player &&
|
||||
c.declaration.card === frame.selection?.card && c.use?.kind === 'cancel' &&
|
||||
c.use.response.kind === 'choamWorthless' && c.use.response.intent === 'Kull Wahad' &&
|
||||
c.use.response.owner === frame.player && c.use.response.recipient === frame.owner &&
|
||||
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response)),
|
||||
'The saved Kull discard has lost its original cost or suspended response.');
|
||||
} else if (continuation?.kind === 'nullentropyDiscard') {
|
||||
const c = continuation;
|
||||
const owner = g.players.find((p) => p.id === c.player);
|
||||
const selected =
|
||||
@@ -8750,6 +8810,11 @@ function semutaOfferSupported(
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
entries: FreshDiscardBatch['entries'],
|
||||
) {
|
||||
if (continuation.kind === 'kullSuccessDiscard' || continuation.kind === 'kullCounterDiscard')
|
||||
return g.semutaPreview === true && g.advanced === false && bareBasicSemutaProfile(g) &&
|
||||
!!byFaction(g, 'richese') && kullSemutaParentSupported(g) && entries.length === 1 &&
|
||||
entries[0].publicFace && entries[0].discardedBy === continuation.player &&
|
||||
entries[0].card.id === continuation.card;
|
||||
// These completed public costs retain the original ordinary lot through the
|
||||
// existing signed continuation. Semuta itself has no during-bid exception;
|
||||
// native Distrans still cannot produce a transfer after that bid starts.
|
||||
@@ -9277,6 +9342,19 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
{ faction: owner.faction, name: 'Semuta Drug' });
|
||||
}
|
||||
if (preventedGiftBeforeClaim) return;
|
||||
if (next.kind === 'kullSuccessDiscard' || next.kind === 'kullCounterDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
if (next.kind === 'kullCounterDiscard') {
|
||||
requireRule(next.declaration && next.use, 'The Kull counter lost its saved continuation.');
|
||||
g.pendingKull = next.frame;
|
||||
g.pendingChoamWorthless = next.declaration;
|
||||
completeKarama(g, getPlayer(g, next.player), next.use, pending.batch.entries[0].card);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (next.kind === 'winnerMandatoryDiscard') {
|
||||
finishWinner(g, getPlayer(g, next.player), next.territory, next.optional);
|
||||
return;
|
||||
@@ -10020,12 +10098,12 @@ function startModules(state: Game): Game {
|
||||
requireRule(g.advanced === false && (g.expansions.length === 2 ||
|
||||
g.expansions.length === 3 && g.expansions.includes('ecaz')) && g.expansions.includes('choam') &&
|
||||
g.expansions.includes('ix') && !!byFaction(g,'choam') &&
|
||||
g.players.every(player => player.faction === 'choam' || player.faction === 'ixians' ||
|
||||
g.players.every(player => player.faction === 'choam' || player.faction === 'richese' || player.faction === 'ixians' ||
|
||||
player.faction === 'tleilaxu' || g.expansions.includes('ecaz') &&
|
||||
(player.faction === 'ecaz' || player.faction === 'moritani') || CLASSIC_FACTIONS[player.faction] === true) &&
|
||||
!options.leaderSkills && !options.ecazTreachery && !g.nexusCards && !g.discoveryEnabled &&
|
||||
!g.homeworlds && !g.techTokens && !g.strongholdCards,
|
||||
'Kull preview needs Basic CHOAM with classic or supported Ix/Ecaz factions, CHOAM and Ix decks, each faction’s deck, and no optional modules.');
|
||||
'Kull preview needs Basic CHOAM with supported factions, CHOAM and Ix decks, each faction’s deck, and no optional modules.');
|
||||
return initializeKullGameForAudit(g);
|
||||
}
|
||||
if (options.ecazTreachery && options.leaderSkills) g.ecazTreachery = true;
|
||||
@@ -12400,10 +12478,10 @@ export function initializeKullGameForAudit(state: Game): Game {
|
||||
state.expansions.includes('ecaz')) && state.expansions.includes('choam') &&
|
||||
state.expansions.includes('ix') && state.players.some(p => p.faction === 'choam') &&
|
||||
state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base' ||
|
||||
!state.advanced && (p.faction === 'ixians' || p.faction === 'tleilaxu' ||
|
||||
!state.advanced && (p.faction === 'richese' || p.faction === 'ixians' || p.faction === 'tleilaxu' ||
|
||||
state.expansions.includes('ecaz') && (p.faction === 'ecaz' || p.faction === 'moritani'))),
|
||||
'Kull preview needs a fresh CHOAM lobby with supported native factions and selected CHOAM and Ix decks.');
|
||||
const g = initializeFactionExpansionsGameForAudit(state);
|
||||
const g = initializeFactionExpansionsGame(state, bareBasicSemutaProfile(state));
|
||||
g.kullPreview = true;
|
||||
return g;
|
||||
}
|
||||
@@ -18070,6 +18148,7 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin
|
||||
}
|
||||
function kullCostCards(g: Game, p: Player, source: 'printed' | 'nexus' = 'printed'): Card[] {
|
||||
const original = g.pendingKull ? kullSource(g, g.pendingKull) : g;
|
||||
if (g.semutaPreview && (!kullSemutaParentSupported(original) || source !== 'printed')) return [];
|
||||
if (source === 'nexus' && (!g.nexusKullPreview || p.faction !== 'choam' || p.ally ||
|
||||
(g.pendingKull?.selection?.source !== 'nexus' && g.nexusCards?.cards?.hands[p.id] !== 'choam')))
|
||||
return [];
|
||||
@@ -18333,6 +18412,24 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
return stage && !stage.closed && windowCardOpportunity(g, effect, stage.source)?.source === stage.source;
|
||||
});
|
||||
})) guardTurnActionPrintedCardCost(g, p, used);
|
||||
if (g.semutaPreview && !g.advanced && g.pendingKull?.stage === 'counter' &&
|
||||
use.kind === 'cancel' && use.response.kind === 'choamWorthless' && use.response.intent === 'Kull Wahad') {
|
||||
const declaration = g.pendingChoamWorthless;
|
||||
requireRule(declaration && kullSemutaParentSupported(kullSource(g, g.pendingKull)),
|
||||
'This Kull counter cannot suspend its current parent for Semuta.');
|
||||
const continuation: KullSemutaDiscardContinuation = {
|
||||
kind: 'kullCounterDiscard', player: p.id, card: used.id,
|
||||
frame: structuredClone(g.pendingKull), declaration: structuredClone(declaration), use,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
stateSignature: '',
|
||||
};
|
||||
g.pendingKull = null; g.pendingChoamWorthless = null;
|
||||
g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
|
||||
continuation.stateSignature = kullSemutaDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'karama:kullCounter', [{card:used, discardedBy:p.id, publicFace:true}], continuation);
|
||||
return;
|
||||
}
|
||||
const semutaRoleCost = g.semutaPreview && semutaKaramaRoleCost(g, p.id, card);
|
||||
const guildShipmentDiscard = semutaRoleCost &&
|
||||
use.kind === 'cancel' && semutaGuildShipmentKaramaResponse(g, use.response);
|
||||
@@ -35177,12 +35274,24 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
const used = discard(g, choam, pending.card);
|
||||
finishNexusChoam(g,pending,'complete');
|
||||
if (pending.effect === 'kull') {
|
||||
const restriction = kullSettledRestriction(g, g.pendingKull!);
|
||||
const frame = structuredClone(g.pendingKull!);
|
||||
const restriction = kullSettledRestriction(g, frame);
|
||||
g.kullRestrictions = [...activeKullRestrictions(g.kullRestrictions, g.turn, g.phase),
|
||||
restriction];
|
||||
log(g, `${getPlayer(g, g.pendingKull!.owner).name} cannot activate ${restriction.purpose
|
||||
? kullPurposeLabel(restriction.purpose) : 'Karama'} for this phase. The attempted card stays held.`);
|
||||
resumeKullAttempt(g, true);
|
||||
if (g.semutaPreview && !g.advanced) {
|
||||
const continuation: KullSemutaDiscardContinuation = {
|
||||
kind: 'kullSuccessDiscard', player: choam.id, card: used.id, frame,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
stateSignature: '',
|
||||
};
|
||||
g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
|
||||
continuation.stateSignature = kullSemutaDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'choam:kull', [{card:used, discardedBy:choam.id, publicFace:true}], continuation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pending.effect === 'kulon') {
|
||||
|
||||
+5
-5
@@ -4755,7 +4755,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Whenever another faction requests a normal revival with a free portion, CHOAM receives a response decision independently of its private hand. Allow the request, or declare La La La for that requester. If the effect succeeds, the whole pending request stops without payment, force movement or quota use; a faction permitted to purchase normal revivals can submit a paid request. If the effect is canceled, the original quote resumes through any Tleilaxu and other applicable responses.',
|
||||
'La La La does not prevent otherwise legal paid revivals or separate Ghola card effects. With Tleilaxu in the game, Fremen may submit a paid request within their current limit after free revival is prevented. Without Tleilaxu, Fremen’s base purchase restriction leaves no normal force return available. Emperor-funded extras remain separate. The prohibition resets when the next Revival phase begins. Card custody is checked at settlement: if CHOAM cashed in the declared card during the response, no Worthless effect occurs and the interrupted revival resumes.',
|
||||
'During Shipment and Movement, use Baliset to select another player and a territory CHOAM occupies. The current implementation prevents that player from entering that territory by movement for the phase while CHOAM remains there; shipment is allowed. A declared move into a CHOAM territory pauses for CHOAM regardless of its private hand. Allow it or declare Baliset. A successful effect leaves the moving forces in place and the movement unspent; a canceled or unavailable card resumes the original move after checking its legality again.',
|
||||
'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has an opt-in Basic lobby preview for CHOAM, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with the CHOAM and Ix decks, each seated faction’s deck and no other modules; the explicit Nexus Kull profile adds Cunning fuel choices at that same reaction, not in every Nexus game. See both Kull guides for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Complete expansion play remains unfinished.',
|
||||
'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has an opt-in Basic lobby preview for CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with the CHOAM and Ix decks, each seated faction’s deck and no other modules; the explicit Nexus Kull profile adds Cunning fuel choices at that same reaction, not in every Nexus game. See both Kull guides for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Complete expansion play remains unfinished.',
|
||||
],
|
||||
related: [
|
||||
'faction-choam',
|
||||
@@ -4778,8 +4778,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.',
|
||||
steps: [
|
||||
'In the lobby, the host can opt into this Basic development preview with CHOAM, Ixians, Tleilaxu, Ecaz, Moritani and classic factions, the CHOAM and Ix decks, each seated faction’s deck, and no other optional modules. It uses printed ordinary Karama. The preview withholds some structured Truthtrance question types and defers unfunded non-CHOAM Karama-dependent bids; leave it off to retain the ordinary current game. A separate Advanced development profile supports Bene Gesserit substitutions and legal special powers. Existing games are not retrofitted, and the warning is not rules-completion approval.',
|
||||
'In the supported Basic printed Kull preview, ordinary, grouped and mixed Truthtrance bribe questions remain available. A pending Kull response temporarily holds bribe controls; its real resolution resumes the existing commitment. Other structured question types remain incomplete in this preview.',
|
||||
'In the lobby, the host can opt into this Basic development preview with CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions, the CHOAM and Ix decks, each seated faction’s deck, and no other optional modules. It uses printed ordinary Karama. The preview withholds some structured Truthtrance question types and defers unfunded non-CHOAM Karama-dependent bids; leave it off to retain the ordinary current game. A separate Advanced development profile supports Bene Gesserit substitutions and legal special powers. Existing games are not retrofitted, and the warning is not rules-completion approval.',
|
||||
'In the supported Basic printed Kull preview without Richese, ordinary, grouped and mixed Truthtrance bribe questions remain available. The existing Semuta preview does not yet admit structured bribe commitments. A pending Kull response temporarily holds bribe controls; its real resolution resumes the existing commitment. Other structured question types remain incomplete in this preview.',
|
||||
'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.',
|
||||
'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.',
|
||||
'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. Basic prevents that player’s Karama activations for this phase. A new Advanced attempt instead prevents only the specific effect: shipment pricing, free Treachery purchase, that faction’s special, or cancellation of the particular advantage. Other effects remain available; printed and substituted Karama share the same restriction. An allied shipment recipient is not automatically the restricted player.',
|
||||
@@ -4788,12 +4788,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Advanced Bene Gesserit is intercepted before Worthless conversion or discard: successful Kull keeps that original unplayed Worthless card held. If CHOAM declines or Kull is countered, the original proceeds to its separate conversion response. Ordinary cancellation of that conversion still leaves its Worthless card discarded. A different BG Worthless counter has its own conversion response; it is never the reserved original.',
|
||||
'Winning overbids whose Karama payment would become unplayable remain deferred. To avoid accepting a bid that the preview cannot settle, a non-CHOAM unfunded Karama-dependent bid is refused privately before commitment, independently of CHOAM’s hidden Kull ownership. CHOAM self-activations are outside interception; normal non-preview bidding is unchanged. An already unfunded winning activation remains guarded. Holding Karama alone is not a reaction trigger. This unfinished-composition fence does not restart bidding, award the lot free, supply new funding or change numeric UI bid limits.',
|
||||
'A live Truthtrance battle or shipment promise also constrains original attempts, Kull costs and counters. A cost that cannot be proved compatible is unavailable before disposal; CHOAM may decline instead. Proven supported cancellations remain available. Pending Kull cannot release a suspended promise, and its counter choices exclude unproven costs so legal AI can progress. Unsupported continuations remain an incomplete preview composition, not a publisher ban or permission to release the promise.',
|
||||
'The different-card counter and before-conversion custody are explicitly selected development policy, not a located publisher clarification. This printed-only profile still excludes Nexus. The separate Nexus Kull profile adds one bounded Cunning interaction without changing older games; Richese/Semuta, optional modules, unpayable-auction recovery and full CHOAM acceptance remain outside this printed preview. Nexus cards do not receive blanket immunity to Karama.',
|
||||
'The different-card counter and before-conversion custody are explicitly selected development policy, not a located publisher clarification. This printed-only profile still excludes Nexus. The separate Nexus Kull profile adds one bounded Cunning interaction without changing older games; Some nested Richese/Semuta parents, optional modules, unpayable-auction recovery and full CHOAM acceptance remain outside this printed preview. Nexus cards do not receive blanket immunity to Karama.',
|
||||
],
|
||||
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
|
||||
related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Opt-in Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Ixians, Tleilaxu, Ecaz and Moritani and retains structured bribe promises through its response; deferred overbid recovery and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-native-ix-runtime.test.ts', 'tests/kull-native-e3-runtime.test.ts', 'tests/kull-ground-movement-runtime.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Opt-in Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, structured bribe promises persist through its response; deferred overbid recovery and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-native-ix-runtime.test.ts', 'tests/kull-native-e3-runtime.test.ts', 'tests/kull-ground-movement-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actKullPurpose, kullPurposeClean, kullPurposePlayer, nextKullPurposeStep,
|
||||
type KullPurposeStep } from './fixture-kull-purpose';
|
||||
|
||||
export const act = actKullPurpose;
|
||||
export const player = kullPurposePlayer;
|
||||
export const clean = kullPurposeClean;
|
||||
|
||||
/** Includes both decks and all visible physical card zones used here. */
|
||||
export function inventory(game: Game): string[] {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(card => card.id).sort();
|
||||
assert.equal(new Set(cards).size,cards.length,'Each original Treachery card occupies one physical zone');
|
||||
return cards;
|
||||
}
|
||||
|
||||
/** Native choices only. The original Richese cache auction is deliberately
|
||||
* Silent: Richese pays one, every other eligible player submits zero. */
|
||||
function next(game: Game): KullPurposeStep | null {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
if (pending && reaction?.stage === 'offer') {
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
return {actor:actor.id,action:{type:'semutaPass',event:pending.batch.event}};
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'richeseCache')
|
||||
return {actor:game.decision.player,action:{type:'decision',event:game.richeseBidding!.event,
|
||||
card:SEMUTA_DRUG_ID,method:'silent'}};
|
||||
const lot = game.richeseAuction;
|
||||
if (lot && !lot.outcome && !game.phaseOpening && !game.response && !game.decision) {
|
||||
assert.equal(lot.method,'silent');
|
||||
const actor = lot.eligible.find(id => !Object.hasOwn(lot.sealed,id)); assert.ok(actor);
|
||||
return {actor,action:{type:'richeseBid',event:lot.event,amount:actor === 'richese' ? 1 : 0,allyPayment:0}};
|
||||
}
|
||||
return nextKullPurposeStep(game);
|
||||
}
|
||||
export function advance(game: Game, until: (state: Game) => boolean, actions: KullPurposeStep[] = []): Game {
|
||||
for (let count=0;count<1800;count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status,'finished','Original Kull/Semuta programme ended before its requested window');
|
||||
const step=next(game); assert.ok(step,'Resolve the current native choice before continuing');
|
||||
game=act(game,step.actor,step.action,actions);
|
||||
}
|
||||
throw Error('Original Kull/Semuta programme did not reach the requested window');
|
||||
}
|
||||
|
||||
/** Public Basic opt-in. Original card order and a safe unopened Spice position
|
||||
* are selected before use; Richese actually buys Semuta in its native auction.
|
||||
* No played hand, spice, board force, pending event or phase is installed. */
|
||||
export function createKullRicheseSemutaFixture() {
|
||||
let game=createGame('KULLSEMUTA',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
|
||||
for (const id of ['richese','emperor','harkonnen'] as const) joinGame(game,newPlayer(id,id,id));
|
||||
const actions:KullPurposeStep[]=[];
|
||||
const send=(actor:string,action:Action)=>{game=act(game,actor,action,actions);};
|
||||
const reach=(until:(state:Game)=>boolean)=>{game=advance(game,until,actions);};
|
||||
send('choam',{type:'moduleOption',module:'kullPreview',enabled:true});
|
||||
for (const p of game.players) send(p.id,{type:'ready'});
|
||||
const lobby=structuredClone(game);
|
||||
send('choam',{type:'start'});
|
||||
assert.equal(game.status,'setup');
|
||||
assert.equal(game.kullPreview,true);
|
||||
assert.equal(game.semutaPreview,true,'Native Richese must retain its Semuta discard opportunity');
|
||||
assert.ok(game.players.every(p=>p.hand.length===0));
|
||||
const originalCards=inventory(game), selected:Game['deck']=[];
|
||||
for (const kind of ['kull','filler','karama','karama'] as const) {
|
||||
const index=game.deck.findIndex(card=>kind==='kull' ? card.id==='ix-kull-wahad' :
|
||||
kind==='karama' ? card.effect==='karama' : card.kind==='worthless' && card.id!=='ix-kull-wahad');
|
||||
assert.ok(index>=0,`Missing unopened ${kind}`); selected.push(game.deck.splice(index,1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
reach(state=>state.status==='playing');
|
||||
const [kull,,karama,counter]=selected.map(card=>card.id);
|
||||
assert.ok(player(game,'choam').hand.some(card=>card.id===kull));
|
||||
assert.ok(player(game,'emperor').hand.some(card=>card.id===karama));
|
||||
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===counter));
|
||||
assert.ok(game.richeseCache?.some(card=>card.id===SEMUTA_DRUG_ID));
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
const index=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery);
|
||||
assert.ok(index>=0); game.spiceDeck.unshift(game.spiceDeck.splice(index,1)[0]);
|
||||
reach(state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && clean(state));
|
||||
const acquired=structuredClone(game);
|
||||
assert.ok(actions.some(step=>step.actor==='richese' && step.action.type==='richeseBid' && step.action.amount===1));
|
||||
reach(state=>state.phase===5 && state.active==='emperor' && clean(state));
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
return {game,lobby,acquired,actions,originalCards,kull,karama,counter,semuta:SEMUTA_DRUG_ID,
|
||||
actor:'emperor',reactor:'choam',counterOwner:'harkonnen',holder:'richese'};
|
||||
}
|
||||
export type KullRicheseSemutaFixture=ReturnType<typeof createKullRicheseSemutaFixture>;
|
||||
export const attempt=(f:KullRicheseSemutaFixture):Action=>
|
||||
({type:'card',mode:'shipment',card:f.karama,target:f.actor});
|
||||
@@ -87,6 +87,13 @@ export function nextSemutaNexusCunningStep(game: Game): SemutaNexusCunningStep {
|
||||
if (!game.phaseOpening && !game.response &&
|
||||
(game.decision?.kind === 'choamMarket' || game.decision?.kind === 'choamMentat'))
|
||||
return { actor: game.decision.player, action: { type: 'decision', done: true } };
|
||||
// Fresh Advanced games use the actual ordered Charity and Revival queues;
|
||||
// the older generic native fixture assumed simultaneous ready actions.
|
||||
if (game.status === 'playing' && !game.phaseOpening && !game.response && !game.decision) {
|
||||
const actor = game.phase === 2 ? game.charityOrder?.remaining[0]
|
||||
: game.phase === 4 ? game.revivalOrder?.remaining[0] : undefined;
|
||||
if (actor) return { actor, action: { type: 'ready' } };
|
||||
}
|
||||
return nextRicheseSkillsTechNativeStep(game);
|
||||
}
|
||||
export function advanceSemutaNexusCunning(
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { act, advance, attempt, clean, createKullRicheseSemutaFixture, inventory, player,
|
||||
type KullRicheseSemutaFixture } from './fixture-kull-richese-semuta';
|
||||
|
||||
const held=(game:Game,id:string,card:string)=>player(game,id).hand.some(row=>row.id===card);
|
||||
function pendingKull(f:KullRicheseSemutaFixture) {
|
||||
const pending=act(f.game,f.actor,attempt(f));
|
||||
assert.equal(pending.pendingKull?.stage,'offer');
|
||||
assert.ok(held(pending,f.actor,f.karama));
|
||||
const reaction=viewGame(pending,f.reactor).kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(row=>row.card.id===f.kull && !row.blocked));
|
||||
return pending;
|
||||
}
|
||||
function declare(f:KullRicheseSemutaFixture,game:Game) {
|
||||
const reaction=viewGame(game,f.reactor).kullReaction; assert.ok(reaction);
|
||||
const declared=act(game,f.reactor,{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
|
||||
assert.equal(declared.pendingKull?.stage,'counter');
|
||||
return declared;
|
||||
}
|
||||
function fresh(game:Game,card:string,discardedBy:string) {
|
||||
const pending=game.pendingTreacheryDiscard; assert.ok(pending,'A fresh public discard must offer Semuta');
|
||||
assert.equal(pending.reaction?.stage,'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry=>({id:entry.card.id,discardedBy:entry.discardedBy,publicFace:entry.publicFace})),
|
||||
[{id:card,discardedBy,publicFace:true}]);
|
||||
assert.equal(game.discard.filter(row=>row.id===card).length,1);
|
||||
return pending.batch.event;
|
||||
}
|
||||
function react(game:Game,f:KullRicheseSemutaFixture,claim:boolean) {
|
||||
const pending=game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
const event=pending.batch.event,card=pending.batch.entries[0].card.id;
|
||||
if (claim) {
|
||||
assert.equal(viewGame(game,f.holder).semutaReaction?.canCommit,true);
|
||||
game=act(game,f.holder,{type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage==='select')
|
||||
game=act(game,f.holder,{type:'semutaSelect',event,card});
|
||||
} else {
|
||||
for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count<game.players.length;count++) {
|
||||
const reaction=game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage==='offer');
|
||||
const actor=game.players.find(row=>!reaction.passed.includes(row.id)); assert.ok(actor);
|
||||
game=act(game,actor.id,{type:'semutaPass',event});
|
||||
}
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event,'Retire exactly the selected fresh batch');
|
||||
return game;
|
||||
}
|
||||
function assertSemutaResult(game:Game,f:KullRicheseSemutaFixture,card:string,claim:boolean) {
|
||||
assert.equal(held(game,f.holder,card),claim);
|
||||
assert.equal(held(game,f.holder,f.semuta),!claim);
|
||||
assert.equal(game.discard.filter(row=>row.id===card).length,Number(!claim));
|
||||
assert.equal(game.discard.filter(row=>row.id===f.semuta).length,Number(claim));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
}
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic native Richese may ${claim?'claim':'pass'} the successfully played Kull`,()=>{
|
||||
const f=createKullRicheseSemutaFixture();
|
||||
const declared=declare(f,pendingKull(f));
|
||||
const pending=advance(declared,state=>!!state.pendingTreacheryDiscard);
|
||||
const event=fresh(pending,f.kull,f.reactor);
|
||||
assert.ok(held(pending,f.actor,f.karama),'The prevented original is still held');
|
||||
assert.equal(pending.karamaShipping,null);
|
||||
const before=structuredClone(pending);
|
||||
assert.throws(()=>act(pending,f.actor,attempt(f)));
|
||||
assert.deepEqual(pending,before);
|
||||
let game=react(pending,f,claim);
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.equal(game.karamaShipping,null);
|
||||
assert.ok(held(game,f.actor,f.karama));
|
||||
assert.ok(viewGame(game,f.actor).karamaBlocked);
|
||||
assertSemutaResult(game,f,f.kull,claim);
|
||||
assert.throws(()=>act(game,f.holder,{type:'semutaCommit',event}));
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic native Richese may ${claim?'claim':'pass'} the distinct Kull-counter Karama`,()=>{
|
||||
const f=createKullRicheseSemutaFixture();
|
||||
const declared=declare(f,pendingKull(f));
|
||||
assert.ok(viewGame(declared,f.counterOwner).responseControls?.cancelCards.includes(f.counter));
|
||||
const pending=act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
|
||||
const event=fresh(pending,f.counter,f.counterOwner);
|
||||
assert.ok(held(pending,f.actor,f.karama),'The original Karama remains reserved until the counter completes');
|
||||
assert.ok(held(pending,f.reactor,f.kull),'The canceled Kull remains held');
|
||||
assert.equal(pending.karamaShipping,null);
|
||||
let game=react(pending,f,claim);
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
assert.ok(held(game,f.reactor,f.kull));
|
||||
assert.equal(game.discard.filter(row=>row.id===f.karama).length,1);
|
||||
assertSemutaResult(game,f,f.counter,claim);
|
||||
assert.throws(()=>act(game,f.holder,{type:'semutaCommit',event}));
|
||||
});
|
||||
|
||||
void test('all minimal bot profiles choose a legal Semuta reaction to Kull',()=>{
|
||||
const f=createKullRicheseSemutaFixture();
|
||||
const pending=advance(declare(f,pendingKull(f)),state=>!!state.pendingTreacheryDiscard);
|
||||
fresh(pending,f.kull,f.reactor);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(pending,f.holder);
|
||||
view.players.find(row=>row.id===f.holder)!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.ok(['semutaPass','semutaCommit'].includes(action.type),difficulty);
|
||||
let game=act(pending,f.holder,action);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage==='select')
|
||||
game=act(game,f.holder,{type:'semutaSelect',event:pending.pendingTreacheryDiscard!.batch.event,card:f.kull});
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.equal(game.karamaShipping,null);
|
||||
assert.ok(held(game,f.actor,f.karama));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
for (const holdsSemuta of [false,true]) void test(`a nested Ornithopter parent with ${holdsSemuta?'held':'unheld'} Semuta hides Kull and preserves decline`,()=>{
|
||||
const f=createKullRicheseSemutaFixture();
|
||||
const source=structuredClone(f.game);
|
||||
// Controlled parent supplement: move the original physical Ornithopter from
|
||||
// Richese's cache into the active player's legal unfinished flight slot.
|
||||
// The exact phase, actor, counters and other physical cards are native.
|
||||
const index=source.richeseCache!.findIndex(card=>card.name==='Ornithopter');
|
||||
assert.ok(index>=0);
|
||||
const card=source.richeseCache!.splice(index,1)[0];
|
||||
source.ornithopter={event:'controlled-kull-semuta-flight',player:f.actor,card,turn:source.turn,
|
||||
mode:'range3',startingMove:player(source,f.actor).moved,completed:0};
|
||||
if (!holdsSemuta) {
|
||||
// A conserved controlled transfer gives the same public parent without a
|
||||
// Semuta holder; the eligibility gate must never inspect that hidden fact.
|
||||
const hand=player(source,f.holder).hand;
|
||||
const semuta=hand.findIndex(row=>row.id===f.semuta); assert.ok(semuta>=0);
|
||||
source.richeseCache!.push(hand.splice(semuta,1)[0]);
|
||||
}
|
||||
const pending=act(source,f.actor,attempt(f));
|
||||
assert.equal(pending.pendingKull?.stage,'offer');
|
||||
const reaction=viewGame(pending,f.reactor).kullReaction; assert.ok(reaction);
|
||||
assert.equal(reaction.canDecline,true);
|
||||
assert.deepEqual(reaction.plays,[],'Unsupported parent blocks the public Kull play independent of hidden Semuta ownership');
|
||||
assert.ok(held(pending,f.reactor,f.kull));
|
||||
const declined=act(pending,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
|
||||
const resumed=advance(declined,clean);
|
||||
assert.equal(resumed.pendingKull,null);
|
||||
assert.equal(resumed.karamaShipping?.owner,f.actor);
|
||||
assert.equal(resumed.discard.filter(row=>row.id===f.karama).length,1);
|
||||
assert.deepEqual(resumed.ornithopter,source.ornithopter);
|
||||
assert.ok(held(resumed,f.reactor,f.kull));
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
void test('Semuta may recover Kull before a suspended CHOAM Worthless effect resumes', () => {
|
||||
const f = createKullRicheseSemutaFixture();
|
||||
let game = structuredClone(f.acquired);
|
||||
// Controlled physical supplement: transfer the original unopened La La La
|
||||
// from the deck to CHOAM, without creating a card or staging a response.
|
||||
const index = game.deck.findIndex(card => card.name === 'La La La');
|
||||
assert.ok(index >= 0, 'The original La La La is still in the deck');
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
player(game, f.reactor).hand.push(card);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
game = act(game, f.reactor, { type: 'card', mode: 'choam', card: card.id, target: f.actor });
|
||||
assert.equal(game.pendingChoamWorthless?.effect, 'laLaLa');
|
||||
assert.equal(game.response?.kind, 'choamWorthless');
|
||||
game = act(game, f.actor, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
assert.equal(game.pendingKull?.stage, 'offer');
|
||||
const reaction = viewGame(game, f.reactor).kullReaction;
|
||||
assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(row => row.card.id === f.kull && !row.blocked));
|
||||
game = act(game, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
|
||||
game = advance(game, state => !!state.pendingTreacheryDiscard);
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending);
|
||||
assert.equal(pending.continuation.kind, 'kullSuccessDiscard');
|
||||
assert.equal(game.pendingChoamWorthless?.effect, 'laLaLa');
|
||||
assert.ok(player(game, f.actor).hand.some(held => held.id === f.karama));
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
game = act(game, f.holder, { type: 'semutaCommit', event: pending.batch.event });
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, f.holder, { type: 'semutaSelect', event: pending.batch.event, card: f.kull });
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.pendingKull, null);
|
||||
assert.ok(player(game, f.holder).hand.some(held => held.id === f.kull));
|
||||
assert.ok(player(game, f.actor).hand.some(held => held.id === f.karama));
|
||||
assert.ok(game.revivalRules?.freeBlocked?.includes(f.actor));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -32,6 +32,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
|
||||
assert.match(html,/Kull Wahad · Basic development preview/);
|
||||
assert.match(html,/does not support structured Truthtrance questions about bids/);
|
||||
assert.match(html,/defers unfunded non-CHOAM Karama-dependent bids/);
|
||||
assert.match(html,/When Richese is seated, structured bribe commitments are also unavailable/);
|
||||
const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false});
|
||||
const unselected = viewGame(off,off.host);
|
||||
assert.equal(unselected.lobbyModules?.kullPreview,false);
|
||||
|
||||
@@ -11,7 +11,7 @@ const held = (g: Game,id: string,card: string) => player(g,id).hand.some(c => c.
|
||||
function reject(g: Game,id: string,action: Action) {
|
||||
const before = structuredClone(g); assert.throws(() => act(g,id,action)); assert.deepEqual(g,before);
|
||||
}
|
||||
function createLobby(advanced = false, expansions: string[] = ['choam','ix'], last: 'guild'|'richese' = 'guild') {
|
||||
function createLobby(advanced = false, expansions: string[] = ['choam','ix'], last: 'guild' = 'guild') {
|
||||
const game = createGame('KULLLOBBY',newPlayer('choam','CHOAM','choam'),advanced,expansions);
|
||||
joinGame(game,newPlayer('emperor','Emperor','emperor'));
|
||||
joinGame(game,newPlayer(last,last,last));
|
||||
@@ -52,7 +52,6 @@ void test('incompatible opt-in starts reject without modifying their original lo
|
||||
for (const config of [
|
||||
{advanced:true,expansions:['choam','ix'],last:'guild' as const,module:false},
|
||||
{advanced:false,expansions:['choam'],last:'guild' as const,module:false},
|
||||
{advanced:false,expansions:['choam','ix'],last:'richese' as const,module:false},
|
||||
{advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true},
|
||||
]) {
|
||||
let g = createLobby(config.advanced,config.expansions,config.last);
|
||||
|
||||
Reference in new issue
Block a user