Enable Basic Kull preview with Tech Tokens
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 05:10:06 +11:00
1 parent 92d5c0ac2e
commit 16f10beecf
11 files changed
+375 -18

No files matched your search

+2 -2
View File
@@ -2274,8 +2274,8 @@ 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. 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.
Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck. Independent Ecaz Treachery Cards are supported. Tech Tokens are supported separately with three to six CHOAM and classic factions.
Combined turn-action questions are available in supported profiles; some structured questions remain unavailable with Tech Tokens. Some Karama uses remain unavailable while your answer is binding.
Some Kull reactions may be unavailable in complex response windows.
</>}{' '}
Unfunded non-CHOAM Karama-dependent bids remain deferred. This does not represent complete CHOAM rules.
+19
View File
@@ -1,5 +1,24 @@
# Kull Wahad: source limits and Karama interruption design
## Basic Tech Tokens — 29 October 2026
**Prototyped / Partial.** A fresh Basic lobby with native CHOAM and classic
factions, exactly the CHOAM and Ix decks, and three to six seats may now keep
printed Kull enabled by default with Tech Tokens. The host can opt out. The
existing user-selected interception and counter procedures apply. This scope
does not include independent Ecaz cards, additional factions or other modules.
Previously created lobbies with the default policy also acquire this eligibility
on their next start; existing started games are not changed. Some structured
Truthtrance questions remain unavailable with Tech Tokens.
The [focused native journeys](../tests/kull-tech-basic-runtime.test.ts) use the
actual selection, start, first Storm and Tech assignment, then exercise Kull
Use, decline and a distinct counter followed by a real Heighliners shipment.
Only the unopened starting deal and safe Spice prefix are selected; four bot
profiles supply legal reactions. Full modes, other Tech interactions and
deployed acceptance remain open.
## Basic native Kull with independent cards — 29 October 2026
**Prototyped / Partial.** Supported fresh Basic native-faction rosters can now
+15
View File
@@ -17,6 +17,21 @@ open mode gates.
## Current checkpoint and work
**Basic Kull and Tech Tokens — 29 October. Prototyped / Partial.**
Three to six CHOAM/classic seats with the Ix and CHOAM decks may use the
existing printed Kull preview with Tech Tokens; the host retains an opt-out.
Native-action journeys cover Use, decline, a distinct counter, a later
Heighliners shipment and minimal legal bot reactions. Thirty related cases,
quick type/lint checks and the production build pass. Some structured questions,
other combinations and deployed acceptance remain open.
[Scope](CHOAM_KULL_DESIGN.md#basic-tech-tokens--29-october-2026).
**Basic mixed-marker battle cards — 29 October. Prototyped / Partial.**
Corrected stale guidance: both Ecaz and Richese can lead the existing Basic
mixed-marker co-side while playing Reinforcements and Harass together. Two
staged physical action journeys complete their battles; full modes remain open.
[Scope](REINFORCEMENTS_RUNTIME.md#basic-mixed-marker-co-side--29-october-2026).
**Bene Gesserit cache cancellation guidance — 29 October. Prototyped / Partial.**
The rules guide now describes the already supported Worthless-card cancellation
of Richese's compulsory cache auction and its counter. Staged Bidding journeys
+12 -1
View File
@@ -7,7 +7,8 @@ rosters, and clean Ecaz/Moritani expansion tables. Classic and selected E1/E2
Worlds retain native sources and their original Tech/Stronghold envelope;
see the [World phase contract](ECAZ_TREACHERY_RULES.md#native-world-phase-modules--17-october-2026).
Supported native Basic/Advanced Occupy retains its own co-present armies;
Basic mixed-marker and other unprofiled co-sides stay guarded.
Basic Ecaz/Richese mixed-marker co-sides also have a focused card journey;
other unprofiled co-sides remain guarded.
The holder may commit it in either Battle Plan card slot. It is neither a weapon
nor a defense: Prescience of a category receives **None**, while the physical
slot stays occupied and hidden until reveal. A named-card inspection still
@@ -16,6 +17,16 @@ fixes the exact card. Harass & Withdraw may share the plan under the
Original or copied Stone Burner composes in clean selected Ix/CHOAM families
and the existing native Advanced Occupy envelope. Other profile boundaries remain.
## Basic mixed-marker co-side — 29 October 2026
**Prototyped / Partial.** Existing Basic Occupy supports a Richese army with
both ordinary forces and a concealed No-Field alongside Ecaz, with the lead
using Reinforcements and Harass together. The [focused actions](../tests/basic-ecaz-mixed-card-battle.test.ts)
exercise each lead, marker reveal, allocation, physical costs and normal
settlement. They use real setup, alliance and marker shipment, with labelled
conserved board and card staging. This supersedes the older mixed-marker guard
statement for this case; it does not certify other combinations or modes.
## Homeworld plan guide with chosen fees — 29 October 2026
**Prototyped / Partial.** Native Homeworld battle-plan completion now carries
+3 -3
View File
@@ -17,7 +17,7 @@ import { createVictoryContinuation, validateVictoryContinuation, voteVictoryCont
type VictoryContinuationResume } from './victory-continuation';
import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift';
import { mobileStrongholdCollectionCapacity } from './mobile-stronghold-collection';
import { kullDefaultEligible } from './kull-default-profile';
import { basicKullTechRoster, kullDefaultEligible } from './kull-default-profile';
import { advancedKullLobbyEligible } from './kull-advanced-profile';
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
@@ -10880,9 +10880,9 @@ function startModules(state: Game): Game {
player.faction === 'tleilaxu' || g.expansions.includes('ecaz') &&
(player.faction === 'ecaz' || player.faction === 'moritani') || CLASSIC_FACTIONS[player.faction] === true) &&
!options.leaderSkills && !g.nexusCards && !g.discoveryEnabled &&
!g.homeworlds && !g.techTokens && !g.strongholdCards;
!g.homeworlds && (!g.techTokens || basicKullTechRoster(g) && !options.ecazTreachery) && !g.strongholdCards;
requireRule(supportedBasic || advancedKullLobbyEligible(state),
'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.');
'Kull preview needs CHOAM and Ix decks. Basic supports selected native factions, independent Ecaz Treachery Cards, or Tech Tokens with three to six CHOAM/classic factions. Advanced supports CHOAM and classic factions without optional modules.');
if (options.ecazTreachery) {
requireRule(supportedBasic, 'Kull with independent Ecaz cards currently needs the supported Basic native roster.');
const initialized = initializeEcazTreacheryGameForAudit(g);
+6 -3
View File
@@ -1,4 +1,5 @@
import { FACTIONS } from './catalog';
import { basicKullTechRoster } from './kull-default-profile';
/** Public lobby configuration only; physical freshness is checked by the initializer. */
export type FactionModuleProfile = {
@@ -134,13 +135,13 @@ export function selectedPhaseModuleRoster(
/** Keep unrelated overlays outside this phase-only permission. Semuta's caller
* additionally retains its old roster envelope below, rather than inheriting
* new selected-family admission through its explicit context flag. */
function phaseModuleContext(game: FactionModuleProfile, semuta = false, independent = false): boolean {
function phaseModuleContext(game: FactionModuleProfile, semuta = false, independent = false, kull = false): boolean {
return typeof game.advanced === 'boolean' &&
!(game.strongholdCards && !game.advanced) && !game.leaderSkills &&
!game.homeworlds && !game.nexusCards && !game.discoveryEnabled && !game.discoveries &&
!game.discoveryStash && !game.greatMaker && (independent ? game.ecazTreachery === true : !game.ecazTreachery) &&
(!game.semutaPreview || (semuta && game.semutaPreview === true)) &&
!game.advancedPreview && !game.kullPreview && !game.nexusKullPreview &&
!game.advancedPreview && (!game.kullPreview || kull) && !game.nexusKullPreview &&
!game.guildBetrayalPreview && !game.richeseBetrayalPreview &&
!game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview &&
!game.nexusHarkonnenBetrayalPreview;
@@ -183,7 +184,9 @@ function legacySemutaTechRoster(game: FactionModuleProfile): boolean {
/** Tech-only Basic/Advanced or original Advanced Strongholds at three-six seats.
* Explicit Semuta validation keeps its earlier family/native/Harkonnen scope. */
export function nativeFactionTechProfile(game: FactionModuleProfile, semuta = false): boolean {
return !!game && phaseModuleContext(game, semuta) && !!game.techTokens &&
const kull = !!game && !semuta && game.kullPreview === true && basicKullTechRoster(game) &&
!game.sandtrout && !game.spiceBankerIncomePreview;
return !!game && phaseModuleContext(game, semuta, false, kull) && !!game.techTokens &&
(semuta ? legacySemutaTechRoster(game) :
selectedPhaseModuleRoster(game) && game.players.length >= 3);
}
+13 -2
View File
@@ -27,6 +27,16 @@ const choam = new Set(['choam', 'richese']);
const ecaz = new Set(['ecaz', 'moritani']);
const blocked = (reason: string): KullDefaultEligibility => ({ eligible: false, reason });
/** Narrow Basic Tech composition: native CHOAM and classic factions only. */
export function basicKullTechRoster(game: Pick<KullDefaultProfile, 'advanced' | 'expansions' | 'players' | 'techTokens'>): boolean {
return game.advanced === false && !!game.techTokens && Array.isArray(game.expansions) &&
game.expansions.length === 2 && game.expansions.includes('ix') && game.expansions.includes('choam') &&
Array.isArray(game.players) && game.players.length >= 3 && game.players.length <= 6 &&
game.players.some(player => player?.faction === 'choam') &&
game.players.every(player => !!player && (player.faction === 'choam' || classic.has(player.faction))) &&
new Set(game.players.map(player => player.faction)).size === game.players.length;
}
/** Public profile eligibility is independent of the host's explicit opt-out.
* A lone CHOAM seat can see the choice while waiting for others; the native
* start still checks readiness, minimum seats and all fresh private inventory.
@@ -51,8 +61,9 @@ export function kullDefaultProfile(game: KullDefaultProfile): KullDefaultEligibi
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 other than the independent Ecaz Treachery Cards.');
game.homeworlds || game.strongholdCards ||
(game.techTokens && (!basicKullTechRoster(game) || options?.ecazTreachery || projected?.ecazTreachery)))
return blocked('Automatic Kull supports independent Ecaz Treachery Cards, or Tech Tokens with three to six CHOAM/classic factions and the Ix and CHOAM decks.');
return { eligible: true, reason: null };
}
+7 -7
View File
@@ -1664,7 +1664,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Lead controls and blocked reasons name the actual chooser and printed Low penalty. Actual Basic wheels expose half-step Ixian totals under either lead, whole steps after Ecaz-only cancellation, zero-support native plans and private Richese mixed bounds. Nine current rendered-table programmes retain original seal/reveal, typed/substitution and Collection controls. Earlier mobile journeys stay separate; no new visual/browser certification.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
{ area: 'AI', status: 'Partial', detail: 'The opponent uses the original shared legal lead adapter at Low Ecaz; strategic refinement remains deferred. Four existing Basic profiles make twelve legal lead/plan/native-Ixian-substitution proposals through original Collection. Existing Advanced legal participation remains separate. Strategy and strength calibration remain deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The printed Low Ecaz opponent-choice penalty is distinct from authorized Advanced arithmetic and provisional main-paragraph Basic arithmetic are distinguished from the conflicting FAQ, specific Duke ruling and deferred interaction refinement.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Current Basic native12-file244/244, types/lint/build and nine controlled gameplay/rendering programmes pass. Public setup and actual Nexus/alliance/marker shipping precede labelled board/card/typed/budget staging; both leads, total3.5, native substitution, cancellation and zero/capped/mixed markers reach Collection. Four existing profiles provide12 legal proposals. Earlier Advanced/mobile/full-game evidence is historical; full modes, optional configurations, comprehensive assurance and deployed acceptance remain open.', evidence: ['tests/basic-ecaz-native-entry.test.ts', 'tests/basic-ecaz-native-occupy.test.ts', 'tests/fixture-basic-ecaz-native.ts', 'tests/fixture-ecaz-occupy-richese.ts', 'tests/ecaz-occupy-mixed-no-field.test.ts', 'tests/fixture-ecaz-occupy-mixed-no-field.ts', 'tests/ecaz-occupy-mixed-no-field-opponent.test.ts', 'tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/basic-ecaz-tleilaxu-occupy.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Current Basic native12-file244/244, types/lint/build and nine controlled gameplay/rendering programmes pass. Public setup and actual Nexus/alliance/marker shipping precede labelled board/card/typed/budget staging; both leads, total3.5, native substitution, cancellation and zero/capped/mixed markers reach Collection. Four existing profiles provide12 legal proposals. Earlier Advanced/mobile/full-game evidence is historical; full modes, optional configurations, comprehensive assurance and deployed acceptance remain open.', evidence: ['tests/basic-ecaz-mixed-card-battle.test.ts', 'tests/basic-ecaz-native-entry.test.ts', 'tests/basic-ecaz-native-occupy.test.ts', 'tests/fixture-basic-ecaz-native.ts', 'tests/fixture-ecaz-occupy-richese.ts', 'tests/ecaz-occupy-mixed-no-field.test.ts', 'tests/fixture-ecaz-occupy-mixed-no-field.ts', 'tests/ecaz-occupy-mixed-no-field-opponent.test.ts', 'tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/basic-ecaz-tleilaxu-occupy.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},
@@ -4843,7 +4843,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 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.',
'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. Separately, Tech Tokens are supported with three to six CHOAM and classic factions; 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',
@@ -4866,9 +4866,9 @@ 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, 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 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. Tech Tokens are separately supported with three to six CHOAM and classic factions; 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.',
'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 supported profiles. Some structured questions remain unavailable when Tech Tokens are selected. 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.',
'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.',
@@ -4887,7 +4887,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ 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'] },
{ area: 'Verification', status: 'Partial', detail: '18 October Advanced scope:12-file111/111, types/lint/build and two genuine-action/rendered-table programmes pass. Selected unopened deal/auction/Spice positions yield real Baliset purchase, paid shipments, repeated-effect rejection and a different-effect cancellation; Basic retains broad scope. Four policies legally pass. Controlled intent tests cover shared auction/special variants and unstamped frames. Earlier SQLite/phone evidence concerns the old scope, not current recovery/browser certification. Original Nexus/alias programmes, wider combinations, final assurance and deployed acceptance remain open.', evidence: ['tests/kull-purpose.test.ts', 'tests/kull-purpose-intents.test.ts', 'tests/kull-purpose-engine.test.ts', 'tests/fixture-kull-purpose.ts', 'tests/choam-kull-engine.test.ts', 'tests/choam-kull-recovery.test.ts', 'tests/choam-kull-controls.test.tsx', 'tests/bot-choam-kull.test.ts', 'tests/choam-kull.test.ts', 'tests/kull-bribe-runtime.test.ts', 'tests/kull-bribe-controls.test.tsx', 'tests/prototype-room.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: '18 October Advanced scope:12-file111/111, types/lint/build and two genuine-action/rendered-table programmes pass. Selected unopened deal/auction/Spice positions yield real Baliset purchase, paid shipments, repeated-effect rejection and a different-effect cancellation; Basic retains broad scope. Four policies legally pass. Controlled intent tests cover shared auction/special variants and unstamped frames. Earlier SQLite/phone evidence concerns the old scope, not current recovery/browser certification. Original Nexus/alias programmes, wider combinations, final assurance and deployed acceptance remain open.', evidence: ['tests/kull-tech-basic-runtime.test.ts', 'tests/kull-default-profile.test.ts', 'tests/kull-purpose.test.ts', 'tests/kull-purpose-intents.test.ts', 'tests/kull-purpose-engine.test.ts', 'tests/fixture-kull-purpose.ts', 'tests/choam-kull-engine.test.ts', 'tests/choam-kull-recovery.test.ts', 'tests/choam-kull-controls.test.tsx', 'tests/bot-choam-kull.test.ts', 'tests/choam-kull.test.ts', 'tests/kull-bribe-runtime.test.ts', 'tests/kull-bribe-controls.test.tsx', 'tests/prototype-room.test.ts'] },
],
},
{
@@ -6655,7 +6655,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In the independent Ecaz Treachery Cards variant, place your held Reinforcements card in either battle-card slot; it is neither a weapon nor a defense. A category Prescience answer is None, not the name of this card. One physical card cannot fill both slots.',
'Have three own forces in reserves when sealing. Classic/paired Ecaz/Moritani Basic/Advanced, clean selected Ix/CHOAM native/base rosters and explicitly selected native Advanced Occupy prototypes spend ordinary reserves first and then elite reserves, moving exactly three to corresponding Tanks subpools at resolution. These are the actual card holder’s reserves, not the variable ally’s pool; no extra forces arrive.',
'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.',
'All-outcome payment and reserve transfer counting as battle losses remain provisional, not publisher rulings. The holder chooses an available ordinary/special reserve mix; older sealed plans without a choice retain normal-first payment. Same-plan Harass pays the original three-reserve fee before returns; returned reserves cannot fund it. In native Advanced Occupy, original/copied Stone ignores +2 normal score but still charges the fee. Basic mixed-marker co-sides, unprofiled armies and other card-specific limits remain guarded.',
'All-outcome payment and reserve transfer counting as battle losses remain provisional, not publisher rulings. The holder chooses an available ordinary/special reserve mix; older sealed plans without a choice retain normal-first payment. Same-plan Harass pays the original three-reserve fee before returns; returned reserves cannot fund it. In native Advanced Occupy, original/copied Stone ignores +2 normal score but still charges the fee. Basic Ecaz/Richese mixed-marker co-sides support the card; unprofiled armies and other card-specific limits remain guarded.',
'With your own concealed No-Field, the provisional native order materializes its reserve-limited counters before this cost. At least three own reserves must remain after that reveal, before any future Harass return; otherwise Reinforcements is unavailable before the first plan seals. This checks only the actual card user, never its ally’s marker or reserves.',
'Classic and selected Ix/CHOAM Basic/Advanced Worlds retain exact native sources for the chosen reserve mix, sealed when ambiguous; unique sources may derive. Own-World fees cannot also fund dial/casualties; original face/support stays, including explosion thresholds. Original phase modules preserve their own prerequisites and earned custody. Native-only World Traitors, typed Tanks and fee-before-Harass stay provisional composition, not a new card FAQ.',
'One canonical physical-name registry now includes the independent cards for current facts and named battle conditions. Naming Reinforcements in a weapon slot does not make it a weapon category. Native questions use actual combat-location labels. Source-aware legal completion, private full-plan/read-only review and the fill button retain exact native sources.',
@@ -6685,7 +6685,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Return your undialed ordinary and elite forces to reserves. Your leader can still be killed. An opponent’s successful Traitor call cancels withdrawal, including mutual calls. A Face Dancer cannot replace the forces already returned.',
'Discard Harass after use, including a win by your own Traitor call. Reveal-before-explosion return and card-specific discard precedence are explicit implementation inferences; neither is a dedicated combined FAQ ruling.',
'For multiple legal physical allocations, choose all undialed ordinary and elite counters by sector after both plans reveal and before traitor declarations. Unique returns are automatic. The selected return is public; it is not an extra sealed plan element. Stone Burner provisionally compares the undialed forces remaining after successful withdrawal, including same-plan, opposing and copied Stone. Additional optional modules remain guarded. The printed card forbids use on your own Homeworld.',
'In supported native Basic/Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves the original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses only the selected lead’s own pool. Returned reserves stay outside losses and Face Dance. Basic mixed markers, unprofiled co-sides and complete-mode acceptance remain guarded.',
'In supported native Basic/Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves the original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses only the selected lead’s own pool. Returned reserves stay outside losses and Face Dance. Basic Ecaz/Richese mixed-marker co-sides support the card; unprofiled co-sides and complete-mode acceptance remain guarded.',
'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.',
'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.',
'Classic and selected Ix/CHOAM Basic/Advanced Worlds may choose the independent cards before original setup. Harass works on Arrakis or a foreign World, never an own World. Return actual undialed board/visitor types through native custody: normal/special to the original primary World except Advanced Emperor normal to Kaitain and Sardaukar to Salusa. Original commitment stays across thresholds; native-only World Traitors/Face Dancers, late defense/explosion and disposal stay.',
+115
View File
@@ -0,0 +1,115 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame,
newPlayer, viewGame, type Game } from '../game/engine';
import { defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS } from './fixture-no-field-reinforcements';
import { ecazOccupyRicheseFixture, finishOccupyRichese, nextOccupyRichese,
occupyRicheseSeat, openOccupyRichese } from './fixture-ecaz-occupy-richese';
/** Select the independent physical cards before the original Basic deal. */
function basicSetup(): Game {
const roster = ['ecaz', 'richese', 'guild', 'choam'] as const;
let game = createGame('BASICMIXEDCARDS', newPlayer(roster[0], roster[0], roster[0]),
false, ['ecaz', 'choam']);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (let i = roster.length - 1; i >= 0; i--) {
if (viewGame(game, roster[i]).playerPositions[roster[i]] !== i + 1)
game = applyAction(game, roster[i], { type: 'seatPosition', position: i + 1 });
}
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game.ecazTreachery = true;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeEcazOccupyGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Finish real post-reveal allocation and traitor opportunities. */
function finishBattle(state: Game, owner: string, opponent: string): Game {
let game = state;
for (let attempt = 0; attempt < 80; attempt++) {
if (game.response || game.phaseOpening) {
game = nextOccupyRichese(game);
continue;
}
if (game.decision?.kind === 'harassWithdraw') {
const decision = game.decision;
const allocation = viewGame(game, decision.player).battle?.harassAllocation;
assert.ok(allocation);
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
returns: defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) });
continue;
}
const battle = viewGame(game, owner).battle;
const voter = !game.decision && battle?.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
if (!voter) return finishOccupyRichese(game);
assert.ok(voter === owner || voter === opponent || game.players.some(player => player.id === voter));
game = applyAction(game, voter, { type: 'traitorCall', call: false });
}
throw Error('Basic mixed Occupy card battle did not finish its native decisions.');
}
for (const lead of ['ecaz', 'ally'] as const) {
void test(`Basic mixed Richese marker with ${lead} leading resolves both independent battle cards`, () => {
const source = ecazOccupyRicheseFixture({ initial: basicSetup(), advanced: false,
allyFaction: 'richese', opponentFaction: 'guild', marker: 3,
mixedRicheseForces: 2, richeseReserves: 8 });
assert.equal(source.initial.advanced, false);
assert.equal(source.initial.ecazTreachery, true);
const owner = lead === 'ecaz' ? source.ecaz : source.richese;
// Conserved staging: return dealt faces to their actual deck, then give
// the lead the two original physical cards before choosing the battle lead.
for (const seat of source.game.players) {
source.game.deck.push(...seat.hand);
seat.hand = [];
}
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_HARASS);
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_REINFORCEMENTS);
let game = openOccupyRichese(source, lead);
const original = structuredClone(game);
const offer = viewGame(game, owner).battle!;
assert.equal(offer.harassWithdraw?.blocked, null);
assert.equal(offer.reinforcements?.blocked, null);
const profile = offer.ecazOccupy!.profile;
assert.ok(profile);
assert.equal(profile.fixedEcazDial, 3);
const leader = occupyRicheseSeat(game, owner).leaders.filter(face => !face.dead && !face.usedAt)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const enemy = occupyRicheseSeat(game, source.opponent).leaders.filter(face => !face.dead && !face.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader && enemy);
const marker = structuredClone(occupyRicheseSeat(game, source.richese).noField);
game = applyAction(game, owner, { type: 'battlePlan', dial: 6, support: 0,
leader: leader.id, weapon: NO_FIELD_REINFORCEMENTS, defense: NO_FIELD_HARASS });
assert.deepEqual(occupyRicheseSeat(game, source.richese).noField, marker);
assert.equal(occupyRicheseSeat(game, source.richese).reserves, 8);
game = applyAction(game, source.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id });
assert.equal(game.battle?.revealed, true);
assert.equal(occupyRicheseSeat(game, source.richese).noField!.deployed, null);
assert.equal(occupyRicheseSeat(game, source.richese).forces[source.location], 5);
assert.equal(occupyRicheseSeat(game, source.richese).reserves, 5);
game = finishBattle(game, owner, source.opponent);
assert.equal(game.lastBattleContext?.result, 'normal');
assert.equal(game.lastBattleContext?.winner, owner);
const ecaz = occupyRicheseSeat(game, source.ecaz);
const richese = occupyRicheseSeat(game, source.richese);
const returned = 2;
assert.equal(occupyRicheseSeat(game, owner).reserves,
occupyRicheseSeat(original, owner).reserves - (lead === 'ally' ? 3 : 0) - 3 + returned);
assert.equal(ecaz.tanks - occupyRicheseSeat(original, source.ecaz).tanks, 3 + (lead === 'ecaz' ? 3 : 0));
assert.equal(richese.tanks - occupyRicheseSeat(original, source.richese).tanks, 3 + (lead === 'ally' ? 3 : 0));
assert.equal(ecaz.forces[source.location] ?? 0, lead === 'ecaz' ? 0 : 2);
assert.equal(richese.forces[source.location] ?? 0, lead === 'ally' ? 0 : 2);
for (const card of [NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS])
assert.equal(game.discard.filter(face => face.id === card).length, 1);
});
}
+19
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { kullDefaultEligible, kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
import { nativeFactionTechProfile } from '../game/faction-module-profile';
const lobby: KullDefaultProfile = {
status: 'lobby', advanced: false, expansions: ['choam', 'ix'],
@@ -65,6 +66,24 @@ void test('public eligibility does not change when the host chooses an opt-out',
assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: selectedOn }), true);
});
void test('Basic Tech Kull has a narrow three-to-six-seat CHOAM and classic roster', () => {
const tech = { ...lobby, players: [{ faction: 'choam' }, { faction: 'atreides' }, { faction: 'guild' }], techTokens: {} };
assert.equal(kullDefaultEligible(tech), true);
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true }), true);
for (const variant of [
{ players: tech.players.slice(0, 2) },
{ players: [{ faction: 'choam' }, { faction: 'ixians' }, { faction: 'guild' }] },
{ expansions: ['ix', 'choam', 'ecaz'] },
{ lobbyOptions: { ecazTreachery: true } },
{ lobbyModules: { ecazTreachery: true } },
{ homeworlds: {} }, { nexusCards: {} }, { leaderSkills: {} }, { advanced: true },
]) assert.equal(kullDefaultEligible({ ...tech, ...variant }), false);
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true, sandtrout: true }), false);
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true, ecazTreachery: true }), false);
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true, advanced: true }), false);
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true }, true), false);
});
void test('profile reads only public faction and configuration data, never hidden hands or spice', () => {
const player = { faction: 'choam', get hand() { throw Error('private hand read'); },
get spice() { throw Error('private spice read'); } };
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import { defaultKullInventory } from './fixture-default-choam-kull';
import { kullPurposeClean, nextKullPurposeStep } from './fixture-kull-purpose';
const player = (game: Game, id: string) => {
const result = game.players.find(seat => seat.id === id);
assert.ok(result);
return result;
};
const held = (game: Game, id: string, card: string) =>
player(game, id).hand.some(item => item.id === card);
/** Continue actual setup, first Storm, auctions and turns. Tech's public choice
* takes priority; the existing native driver handles the remaining decisions. */
function advance(game: Game, until: (game: Game) => boolean) {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const decision = game.decision;
const step = !game.phaseOpening && !game.response && decision?.kind === 'techToken'
? { actor: decision.player, action: { type: 'decision' as const, token: decision.choices[0] } }
: nextKullPurposeStep(game);
assert.ok(step, 'Native setup or turn requires a legal next action');
game = applyAction(game, step.actor, step.action);
}
throw new Error('Native Tech/Kull programme did not reach its boundary');
}
/** Three-seat, public Basic setup and native actions throughout. Only unopened
* starting-deck order and one safe unopened Spice card are selected. No played
* game hands, wallets, Tech ownership, phases or turn order are staged. */
function fixture() {
let game = createGame('KULLTECH', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
for (const faction of ['emperor', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, 'choam', { type: 'techTokens', 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 = applyAction(game, seat.id, { type: 'ready' });
game = applyAction(game, 'choam', { type: 'start' });
assert.equal(game.status, 'setup');
assert.equal(game.kullPreview, true);
assert.ok(game.techTokens);
assert.ok(game.players.every(seat => seat.hand.length === 0));
const cards = defaultKullInventory(game);
const selected: Game['deck'] = [];
for (const kind of ['kull', 'karama', 'karama']) {
const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
assert.ok(index >= 0);
selected.push(game.deck.splice(index, 1)[0]);
}
game.deck.unshift(...selected);
const [kull, karama, counter] = selected.map(card => card.id);
game = advance(game, state => state.status === 'playing');
assert.ok(held(game, 'choam', kull));
assert.ok(held(game, 'emperor', karama));
assert.ok(held(game, 'guild', counter));
const safe = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(safe >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(safe, 1)[0]);
game = advance(game, state => state.phase === 5 && state.active === 'emperor' && kullPurposeClean(state));
const owners = TECH_TOKENS.map(token => game.techTokens![token.id].owner);
assert.equal(new Set(owners).size, 3, 'The real first Storm assigned all three physical Tech tokens');
assert.ok(owners.every(id => game.players.some(seat => seat.id === id)));
assert.deepEqual(defaultKullInventory(game), cards);
return { game, cards, kull, karama, counter, tech: structuredClone(game.techTokens!) };
}
type Fixture = ReturnType<typeof fixture>;
function offer(f: Fixture) {
const game = applyAction(f.game, 'emperor', {
type: 'card', mode: 'shipment', card: f.karama, target: 'emperor',
});
assert.equal(game.pendingKull?.stage, 'offer');
assert.ok(held(game, 'emperor', f.karama));
assert.deepEqual(game.techTokens, f.tech);
const reaction = viewGame(game, 'choam').kullReaction;
assert.ok(reaction?.plays.some(play => play.source === 'printed' && play.card.id === f.kull && !play.blocked));
return game;
}
function declare(f: Fixture, game: Game) {
const reaction = viewGame(game, 'choam').kullReaction;
assert.ok(reaction);
return applyAction(game, 'choam', { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
}
function shipAndCheckTech(game: Game, f: Fixture) {
const before = player(game, 'emperor');
const reserves = before.reserves;
const owner = game.techTokens!.heighliners.owner!;
const existing = game.techTokens!.heighliners.triggeredTurn === game.turn;
const expectedSpice = existing ? game.techTokens!.heighliners.spice : ownedTech(game.techTokens, owner).length;
game = applyAction(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 });
game = advance(game, state => kullPurposeClean(state));
assert.equal(player(game, 'emperor').reserves, reserves - 1);
assert.equal(game.techTokens!.heighliners.owner, owner);
assert.equal(game.techTokens!.heighliners.spice, expectedSpice);
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
assert.deepEqual(defaultKullInventory(game), f.cards);
}
void test('fresh Basic Tech selection keeps the default Kull offer and four legal bot responses', () => {
const f = fixture(), pending = offer(f);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, 'choam');
view.players.find(seat => seat.id === 'choam')!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
assert.equal(action.type, 'kullDecision');
assert.doesNotThrow(() => applyAction(pending, 'choam', action));
}
});
void test('the host can opt out of default Kull in a fresh Tech lobby', () => {
let game = createGame('KULLTECHOFF', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
for (const faction of ['emperor', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, 'choam', { type: 'techTokens', enabled: true });
game = applyAction(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: false });
assert.equal(viewGame(game, 'choam').lobbyModules?.kullPreview, false);
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
game = applyAction(game, 'choam', { type: 'start' });
assert.equal(game.kullPreview ?? false, false);
assert.ok(game.techTokens);
});
void test('declining Kull resumes the original Karama and an actual Tech-bearing shipment', () => {
const f = fixture(), pending = offer(f);
let game = applyAction(pending, 'choam', { type: 'kullDecision', event: pending.pendingKull!.event, decline: true });
game = advance(game, kullPurposeClean);
assert.equal(game.karamaShipping?.owner, 'emperor');
assert.ok(held(game, 'choam', f.kull));
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(game.techTokens, f.tech);
shipAndCheckTech(game, f);
});
void test('successful Kull retains the attempted Karama and an ordinary shipment still earns Tech income', () => {
const f = fixture();
const game = advance(declare(f, offer(f)), kullPurposeClean);
assert.equal(game.karamaShipping, null);
assert.ok(held(game, 'emperor', f.karama));
assert.equal(game.discard.filter(card => card.id === f.kull).length, 1);
assert.ok(viewGame(game, 'emperor').karamaBlocked);
assert.deepEqual(game.techTokens, f.tech);
shipAndCheckTech(game, f);
});
void test('a distinct Karama counters Kull without replacing Tech custody or the original shipment', () => {
const f = fixture(), pending = declare(f, offer(f));
assert.ok(viewGame(pending, 'guild').responseControls?.cancelCards.includes(f.counter));
let game = applyAction(pending, 'guild', { type: 'card', mode: 'cancel', card: f.counter });
game = advance(game, kullPurposeClean);
assert.equal(game.karamaShipping?.owner, 'emperor');
assert.ok(held(game, 'choam', f.kull));
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(game.discard.filter(card => card.id === f.counter).length, 1);
assert.deepEqual(game.techTokens, f.tech);
shipAndCheckTech(game, f);
});