Connect Kull and Semuta with independent cards
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
87d1887139
commit
bc0db31134
11 files changed
+412
-23
No files matched your search
@@ -2258,7 +2258,7 @@ export function GameTable({
|
||||
This preview prevents the attempted specific Karama effect; phase duration is provisional.
|
||||
Structured bid, alliance, Revival, bribe and combined turn-action questions are unavailable.
|
||||
</> : <>
|
||||
Requires Basic CHOAM with 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. Independent Ecaz Treachery Cards are supported; other optional modules are not.
|
||||
Combined turn-action questions are available; some Karama uses remain unavailable while your answer is binding.
|
||||
Some Kull reactions may be unavailable in complex response windows.
|
||||
</>}{' '}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Kull Wahad: source limits and Karama interruption design
|
||||
|
||||
## Basic native Kull with independent cards — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Supported fresh Basic native-faction rosters can now
|
||||
select independent Ecaz Treachery Cards without disabling their default printed
|
||||
Kull preview. With Richese present, the existing Semuta offer can handle the
|
||||
successful Kull discard, a distinct counter's Karama, and the original
|
||||
shipment Karama after Kull declines. Claim or pass resumes the bound attempt;
|
||||
the distinct physical cards retain their original costs and outcomes.
|
||||
|
||||
The [focused native journeys](../tests/kull-independent-native.test.ts) use
|
||||
public Ecaz and Ixian setup, staged conserved card custody and shipment state,
|
||||
and actual decisions; [profile cases](../tests/kull-independent-profile.test.ts)
|
||||
cover the selection. The online ordering remains provisional. Other nested
|
||||
parents and optional modules retain their existing guards; these cases do not
|
||||
certify Kull, a complete faction or a full rules mode.
|
||||
|
||||
## Basic paid-sale responses and Kull — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** The supported Basic Richese/Semuta profile now also
|
||||
|
||||
@@ -17,6 +17,19 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic independent cards, Kull and Residual Poison — 29 October.
|
||||
Prototyped / Partial.** Supported fresh native Basic rosters can enable printed
|
||||
Kull with independent Ecaz Treachery Cards. Focused native Ecaz/Ixian journeys
|
||||
exercise Kull's shipment interruption and the Semuta offer for the successful
|
||||
Kull, a distinct counter, or the original shipment Karama after Kull declines.
|
||||
Separate native Ecaz/Tleilaxu battles exercise Semuta after a clean pre-leader
|
||||
Residual Poison death. These fixtures stage conserved physical cards and forces;
|
||||
79 focused cases pass across twelve files; typecheck, lint and the production
|
||||
build pass. Other nested parents and optional modules retain their existing
|
||||
boundaries.
|
||||
[Kull scope](CHOAM_KULL_DESIGN.md#basic-native-kull-with-independent-cards--29-october-2026);
|
||||
[Semuta scope](SEMUTA_DISCARD_CONTINUATIONS.md#native-residual-poison-and-kull-with-independent-cards--29-october-2026).
|
||||
|
||||
**Semuta with native CHOAM, Ambassadors and Terror — 29 October. Prototyped /
|
||||
Partial.** Supported fresh Basic independent-card games offer Semuta after
|
||||
CHOAM sales and five printed Worthless costs, clean CHOAM/Ixian Ambassador
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Semuta discard producers and continuation inventory
|
||||
|
||||
## Native Residual Poison and Kull with independent cards — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Supported fresh Basic independent-card games with
|
||||
selected native factions can offer a clean pre-leader Residual Poison discard
|
||||
to Semuta. The leader death remains committed once, and claim or pass resumes
|
||||
the original preparation, including its readiness state. The
|
||||
[Ecaz and Tleilaxu cases](../tests/semuta-independent-native-residual.test.ts)
|
||||
use actual battle and reaction decisions after staging conserved cards and
|
||||
forces.
|
||||
|
||||
The independent-card profile also permits supported native printed Kull; its
|
||||
successful discard, a distinct Karama counter and a declined Kull's original
|
||||
shipment Karama can each offer Semuta. The [Kull journeys](../tests/kull-independent-native.test.ts)
|
||||
stage physical cards and shipment state after native Ecaz/Ixian setup. Existing
|
||||
other parent and optional-module guards remain; the response ordering is a
|
||||
provisional digital interpretation, not complete rules certification.
|
||||
|
||||
## Native CHOAM, Ambassador and Terror discards — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In supported fresh Basic Richese games with the
|
||||
@@ -10,8 +27,8 @@ Sabotage, Robbery overflow and Sabotage-gift overflow also offer their original
|
||||
private discarded cards. A claim or pass resumes the original income, draw or
|
||||
Terror effect once. Private discard faces remain hidden until the claimant is
|
||||
entitled to choose. Fixtures use native setup and actions with explicitly staged
|
||||
physical cards, forces and token placements. Overlapping arrivals, Kull and
|
||||
other optional-module combinations remain outside this extension; its digital
|
||||
physical cards, forces and token placements. Overlapping arrivals, other Kull
|
||||
parents and optional-module combinations remain outside this extension; its digital
|
||||
ordering is provisional. [CHOAM cases](../tests/semuta-independent-native-choam.test.ts),
|
||||
[Ambassador cases](../tests/semuta-independent-native-ambassador.test.ts),
|
||||
[Terror cases](../tests/semuta-independent-native-terror.test.ts).
|
||||
|
||||
+24
-12
@@ -6941,7 +6941,8 @@ type KullSemutaDiscardContinuation = Extract<
|
||||
function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKull']>): boolean {
|
||||
const battle = g.battle;
|
||||
const use = frame?.intent.kind === 'ordinary' ? frame.intent.use : undefined;
|
||||
const nativeResponse = !!frame && !g.advanced && bareBasicSemutaProfile(g) &&
|
||||
const nativeResponse = !!frame && !g.advanced &&
|
||||
(bareBasicSemutaProfile(g) || g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g)) &&
|
||||
use?.kind === 'cancel' && JSON.stringify(use.response) === JSON.stringify(frame.resume.response) &&
|
||||
!frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening;
|
||||
let revivalSupported = false;
|
||||
@@ -9477,7 +9478,9 @@ function semutaOfferSupported(
|
||||
!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening;
|
||||
if (continuation.kind === 'kullSuccessDiscard' || continuation.kind === 'kullCounterDiscard')
|
||||
return g.semutaPreview === true && g.advanced === false && bareBasicSemutaProfile(g) &&
|
||||
return (g.semutaPreview === true && bareBasicSemutaProfile(g) ||
|
||||
g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g) &&
|
||||
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction)) && g.advanced === false &&
|
||||
!!byFaction(g, 'richese') && kullSemutaParentSupported(kullSource(g, continuation.frame), continuation.frame) &&
|
||||
(!!g.battle === !!continuation.battleBinding) && entries.length === 1 &&
|
||||
entries[0].publicFace && entries[0].discardedBy === continuation.player &&
|
||||
@@ -9709,7 +9712,8 @@ function semutaOfferSupported(
|
||||
const independentNativeDiscard = ambassadorDiscard || continuation.kind === 'terrorDiscard' ||
|
||||
continuation.kind === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' ||
|
||||
continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' ||
|
||||
continuation.kind === 'choamGamontDiscard' || continuation.kind === 'choamJubbaDiscard';
|
||||
continuation.kind === 'choamGamontDiscard' || continuation.kind === 'choamJubbaDiscard' ||
|
||||
continuation.kind === 'karamaShipmentPriceDiscard';
|
||||
return (g.semutaPreview === true || g.semutaRecruitsPreview === true &&
|
||||
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) &&
|
||||
(independentNativeDiscard || continuation.kind === 'ordinaryCardDiscard' &&
|
||||
@@ -9717,6 +9721,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
continuation.kind === 'battleCleanup' && (continuation.source === 'winner' ||
|
||||
continuation.source === 'moritani' && !g.advanced && continuation.retention?.source === undefined) ||
|
||||
continuation.kind === 'residualPoisonDiscard' ||
|
||||
continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' ||
|
||||
continuation.kind === 'truthtranceDiscard' && continuation.promise === null &&
|
||||
(continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') ||
|
||||
@@ -10845,10 +10850,16 @@ function startModules(state: Game): Game {
|
||||
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 &&
|
||||
!options.leaderSkills && !g.nexusCards && !g.discoveryEnabled &&
|
||||
!g.homeworlds && !g.techTokens && !g.strongholdCards;
|
||||
requireRule(supportedBasic || advancedKullLobbyEligible(state),
|
||||
'Kull preview needs CHOAM and Ix decks with no optional modules. Advanced currently supports CHOAM and classic factions only; Basic also supports the selected native expansion factions.');
|
||||
'Kull preview needs CHOAM and Ix decks. Advanced supports CHOAM and classic factions without optional modules; Basic also supports selected native expansion factions and independent Ecaz Treachery Cards.');
|
||||
if (options.ecazTreachery) {
|
||||
requireRule(supportedBasic, 'Kull with independent Ecaz cards currently needs the supported Basic native roster.');
|
||||
const initialized = initializeEcazTreacheryGameForAudit(g);
|
||||
initialized.kullPreview = true;
|
||||
return initialized;
|
||||
}
|
||||
return initializeKullGameForAudit(g);
|
||||
}
|
||||
if (options.ecazTreachery && options.leaderSkills) g.ecazTreachery = true;
|
||||
@@ -13081,7 +13092,8 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
// Family/roster checks below remain authoritative; optional configurations
|
||||
// retain their separate registered profiles rather than inheriting admission.
|
||||
const basicNatives = g.advanced === false && !g.strongholdCards &&
|
||||
((!g.ecazTreachery && (!g.techTokens || nativeFactionTechProfile(g))) || selectedIndependentCardsProfile(g));
|
||||
((!g.ecazTreachery && (!g.techTokens || nativeFactionTechProfile(g))) || selectedIndependentCardsProfile(g) ||
|
||||
g.kullPreview === true && selectedIndependentCardsProfile({...g,kullPreview:undefined}));
|
||||
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz')) return true;
|
||||
if (g.leaderSkills && nativeEcazLeaderSkillsProfile(g)) return true;
|
||||
if (g.ecazOccupyPreview === true && (mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
|
||||
@@ -13211,7 +13223,7 @@ function basicIndependentRecruitsSemutaProfile(g: Game): boolean {
|
||||
g.expansions.includes(faction(p.faction).expansion)) &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
|
||||
!g.kullPreview && !g.nexusKullPreview && !g.moritaniAssassinatePreview;
|
||||
!g.nexusKullPreview && !g.moritaniAssassinatePreview;
|
||||
}
|
||||
function semutaCardProducerEnabled(g: Game): boolean {
|
||||
return g.semutaPreview === true || g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g);
|
||||
@@ -19469,7 +19481,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, g.pendingKull ?? undefined) || source !== 'printed')) return [];
|
||||
if (semutaCardProducerEnabled(g) && (!kullSemutaParentSupported(original, g.pendingKull ?? undefined) || 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 [];
|
||||
@@ -19884,7 +19896,7 @@ 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' &&
|
||||
if (semutaCardProducerEnabled(g) && !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), g.pendingKull),
|
||||
@@ -19903,7 +19915,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
stageTreacheryDiscard(g, 'karama:kullCounter', [{card:used, discardedBy:p.id, publicFace:true}], continuation);
|
||||
return;
|
||||
}
|
||||
const semutaRoleCost = g.semutaPreview && semutaKaramaRoleCost(g, p.id, card);
|
||||
const semutaRoleCost = semutaCardProducerEnabled(g) && semutaKaramaRoleCost(g, p.id, card);
|
||||
const guildShipmentDiscard = semutaRoleCost &&
|
||||
use.kind === 'cancel' && semutaGuildShipmentKaramaResponse(g, use.response);
|
||||
const bgConversionCancelDiscard = semutaRoleCost &&
|
||||
@@ -30821,7 +30833,7 @@ function playResidualPoison(
|
||||
`${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`,
|
||||
{ faction: p.faction, name: 'Residual Poison' },
|
||||
);
|
||||
if (g.semutaPreview || g.semutaRecruitsPreview && basicIndependentSemutaProfile(g)) {
|
||||
if (g.semutaPreview || g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g)) {
|
||||
const continuation: ResidualPoisonDiscardContinuation = {
|
||||
kind: 'residualPoisonDiscard', player: p.id, target: target.id,
|
||||
victim: victim.id, battleEvent: b.event!, card: used.id,
|
||||
@@ -37121,7 +37133,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
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) {
|
||||
if (semutaCardProducerEnabled(g) && !g.advanced) {
|
||||
const continuation: KullSemutaDiscardContinuation = {
|
||||
kind: 'kullSuccessDiscard', player: choam.id, card: used.id, frame,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
|
||||
@@ -48,11 +48,11 @@ export function kullDefaultProfile(game: KullDefaultProfile): KullDefaultEligibi
|
||||
new Set(game.players.map(player => player.faction)).size !== game.players.length)
|
||||
return blocked('Automatic Kull needs one to six distinct factions, including CHOAM, with their selected decks.');
|
||||
const options = game.lobbyOptions, projected = game.lobbyModules;
|
||||
if (options?.leaderSkills || options?.ecazTreachery || projected?.leaderSkills || projected?.ecazTreachery ||
|
||||
if (options?.leaderSkills || projected?.leaderSkills ||
|
||||
projected?.nexusCards || projected?.discoveries || game.leaderSkills || game.ecazTreachery || game.nexusCards ||
|
||||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.homeworlds || game.techTokens || game.strongholdCards)
|
||||
return blocked('Automatic Kull currently excludes optional modules.');
|
||||
return blocked('Automatic Kull currently excludes optional modules other than the independent Ecaz Treachery Cards.');
|
||||
return { eligible: true, reason: null };
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -4840,7 +4840,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 a selectable Basic preview, on by default in new eligible lobbies, 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.',
|
||||
'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has a selectable Basic preview, on by default in new eligible lobbies, for CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with the CHOAM and Ix decks and each seated faction’s deck. Independent Ecaz Treachery Cards may also be selected; other optional modules remain excluded. 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',
|
||||
@@ -4863,7 +4863,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 a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. Basic supports Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. In an Advanced preview lobby, the host may explicitly select printed Kull with CHOAM, classic factions and exactly the CHOAM and Ix decks, without optional modules. Advanced supports Bene Gesserit substitutions and legal special powers. Both previews withhold some structured questions and defer unfunded non-CHOAM Karama-dependent bids. Existing games are not retrofitted, and this warning does not certify complete rules.',
|
||||
'In a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. Basic supports Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck, optionally with independent Ecaz Treachery Cards; other optional modules are excluded. In an Advanced preview lobby, the host may explicitly select printed Kull with CHOAM, classic factions and exactly the CHOAM and Ix decks, without optional modules. Advanced supports Bene Gesserit substitutions and legal special powers. Both previews withhold some structured questions and defer unfunded non-CHOAM Karama-dependent bids. Existing games are not retrofitted, and this warning does not certify complete rules.',
|
||||
'In supported Basic games with independent Ecaz Treachery Cards and Richese, Semuta may claim the discarded successful Kull, a distinct Karama counter, or the original shipment Karama when Kull declines. The original attempt continues under its existing rules; other nested reactions remain incomplete.',
|
||||
'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid, ordinary bribe, Revival and alliance questions are available, including with Richese and Semuta. Combined turn-action questions are also available in this profile. A pending Kull response holds their controls while the original transaction resolves; some Karama uses remain guarded. Kull and Semuta also retain a paid ordinary or cache sale when Karama targets Harkonnen’s bonus or Emperor income, and an ordinary sale when it targets an Ixian ally’s replacement. Wider interactions remain incomplete.',
|
||||
'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.',
|
||||
@@ -6620,7 +6621,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
|
||||
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
|
||||
'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. Supported battle cleanup, native Moritani allied retention, Ixian substitution, CHOAM sales and Worthless costs, clean Ambassador and Terror discards, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. The original effect stays committed; other Semuta opportunities remain unfinished.',
|
||||
'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. Supported battle cleanup, clean pre-leader Residual Poison, native Moritani allied retention, Ixian substitution, CHOAM sales and Worthless costs, clean Ambassador and Terror discards, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. The original effect stays committed; other Semuta opportunities remain unfinished.',
|
||||
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
|
||||
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
|
||||
|
||||
@@ -40,10 +40,10 @@ void test('ongoing games, Advanced rules, absent CHOAM and unsupported deck sele
|
||||
}
|
||||
});
|
||||
|
||||
void test('each optional module blocks automatic entry from both engine and public lobby shapes', () => {
|
||||
void test('other optional modules block automatic entry from both engine and public lobby shapes', () => {
|
||||
for (const option of [
|
||||
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
|
||||
{ lobbyModules: { leaderSkills: true } }, { lobbyModules: { ecazTreachery: true } },
|
||||
{ lobbyOptions: { leaderSkills: true } },
|
||||
{ lobbyModules: { leaderSkills: true } },
|
||||
{ lobbyModules: { nexusCards: true } }, { lobbyModules: { discoveries: true } },
|
||||
{ leaderSkills: {} }, { ecazTreachery: true }, { nexusCards: {} }, { discoveryEnabled: true },
|
||||
{ discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} },
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
||||
import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** A fresh public Basic lobby and actual native setup. For the targeted phase
|
||||
* window, all original hands are returned to the deck, then four existing
|
||||
* physical cards are moved to their owners; the phase and turn are staged. */
|
||||
function fixture(native: 'ecaz' | 'ixians' = 'ecaz') {
|
||||
let game = createGame('KULLINDEPENDENT', newPlayer('choam','CHOAM','choam'), false,
|
||||
['choam','ix','ecaz']);
|
||||
for (const faction of ['richese','atreides',native] as const)
|
||||
joinGame(game,newPlayer(faction,faction,faction));
|
||||
game = act(game,'choam',{type:'moduleOption',module:'ecazTreachery',enabled:true});
|
||||
assert.equal(viewGame(game,'choam').lobbyModules?.kullDefault,true);
|
||||
assert.equal(viewGame(game,'choam').lobbyModules?.kullPreview,true);
|
||||
for (const seat of game.players) game=act(game,seat.id,{type:'ready'});
|
||||
game=act(game,'choam',{type:'start'});
|
||||
assert.equal(game.status,'setup');
|
||||
assert.equal(game.advanced,false);
|
||||
assert.equal(game.kullPreview,true);
|
||||
assert.equal(game.semutaRecruitsPreview,true);
|
||||
assert.equal(game.deck.length,50);
|
||||
assert.equal(game.richeseCache?.length,10);
|
||||
const cards=inventory(game);
|
||||
assert.equal(cards.length,60);
|
||||
assert.ok(cards.includes('ix-kull-wahad'));
|
||||
assert.ok(cards.includes(SEMUTA_DRUG_ID));
|
||||
for (let count=0;game.status==='setup' && count<200;count++) {
|
||||
const step=nextRicheseStrongholdsNativeStep(game); assert.ok(step);
|
||||
game=act(game,step.actor,step.action);
|
||||
}
|
||||
assert.equal(game.status,'playing');
|
||||
for (const seat of game.players) game.deck.push(...seat.hand.splice(0));
|
||||
const karamas=game.deck.filter(card=>card.effect==='karama').map(card=>card.id);
|
||||
assert.ok(karamas.length>=2);
|
||||
const [karama,counter]=karamas;
|
||||
moveSemutaEmperorCard(game,'ix-kull-wahad','choam');
|
||||
moveSemutaEmperorCard(game,karama,'atreides');
|
||||
moveSemutaEmperorCard(game,counter,native);
|
||||
moveSemutaEmperorCard(game,SEMUTA_DRUG_ID,'richese');
|
||||
assert.deepEqual(inventory(game),cards);
|
||||
Object.assign(game,{turn:2,phase:5,active:'atreides',ready:[],response:null,
|
||||
decision:null,phaseOpening:null,choamMarket:null,
|
||||
movementRemaining:game.players.map(seat=>seat.id)});
|
||||
for (const seat of game.players) {seat.shipped=false;seat.moved=0;}
|
||||
return {game,cards,karama,counter,native,kull:'ix-kull-wahad',holder:'richese',actor:'atreides'};
|
||||
}
|
||||
type Fixture=ReturnType<typeof fixture>;
|
||||
const held=(game:Game,seat:string,card:string)=>player(game,seat).hand.some(row=>row.id===card);
|
||||
function offer(f:Fixture) {
|
||||
const game=act(f.game,f.actor,{type:'card',mode:'shipment',card:f.karama,target:f.actor});
|
||||
assert.equal(game.pendingKull?.stage,'offer');
|
||||
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(play=>play.source==='printed' && play.card.id===f.kull && !play.blocked));
|
||||
assert.ok(held(game,f.actor,f.karama));
|
||||
return game;
|
||||
}
|
||||
function declare(f:Fixture,game:Game) {
|
||||
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
|
||||
game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
|
||||
assert.equal(game.pendingKull?.stage,'counter');
|
||||
return game;
|
||||
}
|
||||
function semuta(game:Game,f:Fixture,card:string,discardedBy:string,claim:boolean) {
|
||||
const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
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(viewGame(game,f.holder).semutaReaction?.canCommit,true);
|
||||
const event=pending.batch.event;
|
||||
if (claim) {
|
||||
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 seat=game.players.find(row=>!reaction.passed.includes(row.id)); assert.ok(seat);
|
||||
game=act(game,seat.id,{type:'semutaPass',event});
|
||||
}
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const native of ['ecaz','ixians'] as const) void test(`fresh Basic independent Ecaz cards and native ${native} offer default Kull; decline resumes Karama`,()=>{
|
||||
const f=fixture(native), pending=offer(f);
|
||||
const reaction=viewGame(pending,'choam').kullReaction; assert.ok(reaction);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(pending,'choam'); view.players.find(row=>row.id==='choam')!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.equal(action.type,'kullDecision',difficulty);
|
||||
assert.doesNotThrow(()=>act(pending,'choam',action),difficulty);
|
||||
}
|
||||
let game=act(pending,'choam',{type:'kullDecision',event:reaction.event,decline:true});
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.ok(held(game,'choam',f.kull));
|
||||
assert.equal(game.discard.filter(row=>row.id===f.karama).length,1);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} original shipment Karama after Kull declines`,()=>{
|
||||
const f=fixture(), offered=offer(f);
|
||||
const reaction=viewGame(offered,'choam').kullReaction; assert.ok(reaction);
|
||||
const pending=act(offered,'choam',{type:'kullDecision',event:reaction.event,decline:true});
|
||||
assert.equal(pending.karamaShipping,null);
|
||||
let game=semuta(pending,f,f.karama,f.actor,claim);
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
assert.equal(held(game,f.holder,f.karama),claim);
|
||||
assert.equal(held(game,'choam',f.kull),true);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} successful printed Kull`,()=>{
|
||||
const f=fixture(), pending=advance(declare(f,offer(f)),state=>!!state.pendingTreacheryDiscard);
|
||||
assert.ok(held(pending,f.actor,f.karama));
|
||||
assert.equal(pending.karamaShipping,null);
|
||||
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(action.type==='semutaPass'||action.type==='semutaCommit',difficulty);
|
||||
assert.doesNotThrow(()=>act(pending,f.holder,action),difficulty);
|
||||
}
|
||||
let game=semuta(pending,f,f.kull,'choam',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);
|
||||
assert.equal(held(game,f.holder,f.kull),claim);
|
||||
assert.equal(held(game,f.holder,SEMUTA_DRUG_ID),!claim);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} distinct Kull-counter Karama`,()=>{
|
||||
const f=fixture(), declared=declare(f,offer(f));
|
||||
assert.ok(viewGame(declared,f.native).responseControls?.cancelCards.includes(f.counter));
|
||||
const pending=act(declared,f.native,{type:'card',mode:'cancel',card:f.counter});
|
||||
assert.ok(held(pending,f.actor,f.karama));
|
||||
assert.ok(held(pending,'choam',f.kull));
|
||||
let game=semuta(pending,f,f.counter,f.native,claim);
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
assert.ok(held(game,'choam',f.kull));
|
||||
assert.equal(game.discard.filter(row=>row.id===f.karama).length,1);
|
||||
assert.equal(held(game,f.holder,f.counter),claim);
|
||||
assert.equal(held(game,f.holder,SEMUTA_DRUG_ID),!claim);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import { kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
|
||||
|
||||
const lobby: KullDefaultProfile = {
|
||||
status: 'lobby', advanced: false, expansions: ['choam', 'ix', 'ecaz'],
|
||||
players: [{faction:'choam'}, {faction:'ecaz'}, {faction:'richese'}],
|
||||
};
|
||||
|
||||
void test('independent Ecaz cards do not disable the Basic native Kull lobby profile', () => {
|
||||
for (const selection of [
|
||||
{},
|
||||
{lobbyOptions:{ecazTreachery:true}},
|
||||
{lobbyModules:{ecazTreachery:true}},
|
||||
{lobbyOptions:{ecazTreachery:true},lobbyModules:{ecazTreachery:true}},
|
||||
]) assert.deepEqual(kullDefaultProfile({...lobby,...selection}), {eligible:true,reason:null});
|
||||
assert.deepEqual(kullDefaultProfile({...lobby,expansions:['choam','ix'],
|
||||
players:[{faction:'choam'},{faction:'atreides'}],lobbyOptions:{ecazTreachery:true}}),
|
||||
{eligible:true,reason:null}, 'the independent cards do not require the Ecaz faction or deck');
|
||||
});
|
||||
|
||||
void test('the actual public lobby retains the Kull default when independent cards are selected', () => {
|
||||
let game = createGame('KULLINDEPENDENTPROFILE',newPlayer('c','CHOAM','choam'),false,['choam','ix','ecaz']);
|
||||
joinGame(game,newPlayer('e','Ecaz','ecaz'));
|
||||
let view = viewGame(game,'c');
|
||||
assert.equal(view.lobbyModules?.ecazTreachery,false);
|
||||
assert.equal(view.lobbyModules?.kullDefault,true);
|
||||
assert.equal(view.lobbyModules?.kullPreview,true);
|
||||
game = applyAction(game,'c',{type:'moduleOption',module:'ecazTreachery',enabled:true});
|
||||
view = viewGame(game,'c');
|
||||
assert.equal(view.lobbyModules?.ecazTreachery,true);
|
||||
assert.equal(view.lobbyModules?.kullDefault,true);
|
||||
assert.equal(view.lobbyModules?.kullPreview,true);
|
||||
game = applyAction(game,'c',{type:'moduleOption',module:'kullPreview',enabled:false});
|
||||
assert.equal(viewGame(game,'c').lobbyModules?.kullDefault,true);
|
||||
assert.equal(viewGame(game,'c').lobbyModules?.kullPreview,false);
|
||||
game = applyAction(game,'c',{type:'moduleOption',module:'kullPreview',enabled:true});
|
||||
assert.equal(viewGame(game,'c').lobbyModules?.kullPreview,true);
|
||||
assert.equal(viewGame(game,'c').lobbyModules?.ecazTreachery,true);
|
||||
});
|
||||
|
||||
void test('all other modules and an already-initialized card marker remain excluded', () => {
|
||||
const selected = {...lobby,lobbyOptions:{ecazTreachery:true}};
|
||||
for (const other of [
|
||||
{lobbyOptions:{ecazTreachery:true,leaderSkills:true}},
|
||||
{lobbyModules:{ecazTreachery:true,leaderSkills:true}},
|
||||
{lobbyModules:{ecazTreachery:true,nexusCards:true}},
|
||||
{lobbyModules:{ecazTreachery:true,discoveries:true}},
|
||||
{leaderSkills:{}},{ecazTreachery:true},{nexusCards:{}},{discoveryEnabled:true},
|
||||
{discoveries:{}},{discoveryStash:{}},{greatMaker:{}},{homeworlds:{}},
|
||||
{techTokens:{}},{strongholdCards:{}},
|
||||
]) {
|
||||
const result = kullDefaultProfile({...selected,...other});
|
||||
assert.equal(result.eligible,false);
|
||||
assert.ok(result.reason);
|
||||
}
|
||||
assert.equal(kullDefaultProfile({...selected,advanced:true}).eligible,false);
|
||||
assert.equal(kullDefaultProfile({...selected,status:'setup'}).eligible,false);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
const act = (g: Game, id: string, action: Action) => applyAction(g, id, action);
|
||||
type Native = 'tleilaxu' | 'ecaz';
|
||||
|
||||
/** Public selected-family setup; the later forces and held physical cards are
|
||||
* deliberately moved between their original pools. Battle selection and both
|
||||
* preparation controls, Poison and Semuta are actual actions. */
|
||||
function fixture(native: Native) {
|
||||
let game = createGame('SEMUTANATIVERESIDUAL', newPlayer(native, native, native), false,
|
||||
['choam', 'ix', 'ecaz']);
|
||||
for (const faction of ['atreides', 'richese', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = act(game, native, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
||||
game = act(game, native, {type:'start'});
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const original = inventory(game);
|
||||
for (const seat of game.players) {
|
||||
game.deck.push(...seat.hand); seat.hand = [];
|
||||
seat.reserves += seat.tanks + Object.values(seat.forces).reduce((sum, n) => sum + n, 0);
|
||||
seat.tanks = 0; seat.forces = {}; seat.shipped = false; seat.moved = 0;
|
||||
}
|
||||
const take = (id: string) => {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
|
||||
const index = zone.findIndex(card => card.id === id);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw Error(`Missing physical card ${id}`);
|
||||
};
|
||||
const poisonId = game.richeseCache!.find(card => card.effect === 'residualPoison')!.id;
|
||||
player(game, native).hand.push(take(poisonId));
|
||||
player(game, 'guild').hand.push(take(SEMUTA_DRUG_ID));
|
||||
const location = 'red_chasm:7';
|
||||
for (const id of [native, 'atreides']) {
|
||||
player(game, id).reserves -= 3; player(game, id).forces[location] = 3;
|
||||
}
|
||||
Object.assign(game, {turn:2, phase:6, storm:18, active:native,
|
||||
order:[native, 'atreides', 'richese', 'guild'], ready:[], response:null,
|
||||
decision:null, phaseOpening:null, movementRemaining:[]});
|
||||
assert.deepEqual(inventory(game), original);
|
||||
game = act(game, native, {type:'chooseBattle', territory:'red_chasm', target:'atreides'});
|
||||
for (let count = 0; count < 30; count++) {
|
||||
if (game.response) {
|
||||
const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next);
|
||||
game = act(game, next.id, {type:'passResponse'});
|
||||
} else if (game.battle?.preparation) {
|
||||
game = act(game, game.battle.preparation.owner, {type:'declineBattlePower'});
|
||||
} else if (game.decision?.kind === 'fullPlanOffer') {
|
||||
game = act(game, game.decision.player, {type:'decision', decline:true});
|
||||
} else break;
|
||||
}
|
||||
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
|
||||
const quote = viewGame(game, native).residualPoison;
|
||||
assert.ok(quote && !quote.blocked && quote.event && quote.target);
|
||||
return {game, native, holder:'guild', poisonId, event:game.battle.event!, original,
|
||||
poisonAction:{type:'card', card:poisonId, target:quote.target, event:quote.event} as Action};
|
||||
}
|
||||
|
||||
for (const native of ['tleilaxu', 'ecaz'] as const) for (const ready of ['owner', 'opponent'] as const)
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Basic ${native} Residual Poison ${ready}-ready Semuta ${claim ? 'claim' : 'pass'} resumes preparation`, () => {
|
||||
const f = fixture(native), readyPlayer = ready === 'owner' ? native : 'atreides';
|
||||
let game = act(f.game, readyPlayer, {type:'battlePreparationReady', event:f.event});
|
||||
const before = structuredClone(game), deaths = player(game, 'atreides').leaders.filter(leader => leader.dead).map(leader => leader.id);
|
||||
game = act(game, native, f.poisonAction);
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.equal(pending.continuation.kind, 'residualPoisonDiscard');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy, entry.publicFace]), [[f.poisonId, native, true]]);
|
||||
assert.deepEqual(game.battle!.preLeader!.ready, [readyPlayer]);
|
||||
const newlyDead = player(game, 'atreides').leaders.filter(leader => leader.dead && !deaths.includes(leader.id));
|
||||
assert.equal(newlyDead.length, 1);
|
||||
for (const seat of game.players) {
|
||||
const view = viewGame(game, seat.id);
|
||||
assert.deepEqual(view.semutaReaction?.candidates, []);
|
||||
assert.equal(view.semutaReaction?.canCommit, seat.id === f.holder);
|
||||
}
|
||||
if (ready === 'opponent') for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.holder); view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass');
|
||||
assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action));
|
||||
}
|
||||
const event = pending.batch.event;
|
||||
if (claim) {
|
||||
game = act(game, f.holder, {type:'semutaCommit', event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, f.holder, {type:'semutaSelect', event, card:f.poisonId});
|
||||
} else for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(game.battle!.preLeader!.ready, ready === 'owner' ? [] : ['atreides']);
|
||||
assert.deepEqual(game.battle!.plans, before.battle!.plans);
|
||||
assert.equal(player(game, 'atreides').leaders.find(leader => leader.id === newlyDead[0].id)?.dead, true);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === f.poisonId), claim);
|
||||
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
|
||||
assert.equal(game.discard.some(card => card.id === f.poisonId), !claim);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
assert.throws(() => act(game, f.holder, {type:'semutaCommit', event}));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user