From 92c68ec4f63a7a0d6cbbcd0e975c6eafd54d06f1 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Tue, 27 Oct 2026 19:23:23 +1100
Subject: [PATCH] Expose Basic Kull preview in lobby
---
components/game-table.tsx | 12 ++
docs/CHOAM_KULL_DESIGN.md | 38 +++++-
docs/CURRENT_STATUS.md | 11 ++
docs/IMPLEMENTATION_STATUS.md | 16 +++
docs/RULE_DECISIONS.md | 12 ++
game/engine.ts | 18 ++-
game/faction-reference.ts | 2 +-
game/reference.ts | 4 +-
tests/fixture-public-choam-kull.ts | 66 ++++++++++
tests/public-choam-kull-controls.test.tsx | 54 ++++++++
tests/public-choam-kull-runtime.test.ts | 152 ++++++++++++++++++++++
11 files changed, 375 insertions(+), 10 deletions(-)
create mode 100644 tests/fixture-public-choam-kull.ts
create mode 100644 tests/public-choam-kull-controls.test.tsx
create mode 100644 tests/public-choam-kull-runtime.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index bfff4520..139ecca0 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2203,6 +2203,18 @@ export function GameTable({
{label} · preview
))}
+
+ act({ type: 'moduleOption', module: 'kullPreview', enabled: e.target.checked })} />
+ Kull Wahad · Basic development preview
+
+ {g.lobbyModules?.kullPreview &&
+ Requires Basic CHOAM and classic factions, both CHOAM and Ix decks, and no other optional modules.
+ This preview does not support structured Truthtrance questions about bids, bribes, alliances, Revival,
+ or combined turn actions, and it defers unfunded non-CHOAM Karama-dependent bids. It does not represent complete
+ CHOAM rules. Every player must ready again after changing it.
+
}
['leaderSkills', 'ecazTreachery'].includes(key) && value === true),
+ Object.entries(options).every(([key, value]) => ['leaderSkills', 'ecazTreachery', 'kullPreview'].includes(key) && value === true),
'Invalid lobby module options.');
}
function lobbyModulesSelected(g: Game): boolean {
- return !!g.lobbyOptions?.leaderSkills || !!g.lobbyOptions?.ecazTreachery ||
+ return !!g.lobbyOptions?.leaderSkills || !!g.lobbyOptions?.ecazTreachery || !!g.lobbyOptions?.kullPreview ||
!!g.nexusCards || g.discoveryEnabled === true || !!g.homeworlds;
}
/** Route a normal start through the existing genuine setup for the selected modules. */
@@ -10014,6 +10014,15 @@ function startModules(state: Game): Game {
const options = state.lobbyOptions ?? {};
const g = structuredClone(state);
delete g.lobbyOptions;
+ if (options.kullPreview) {
+ requireRule(g.advanced === false && g.expansions.length === 2 && g.expansions.includes('choam') &&
+ g.expansions.includes('ix') && !!byFaction(g,'choam') &&
+ g.players.every(player => player.faction === 'choam' || CLASSIC_FACTIONS[player.faction] === true) &&
+ !options.leaderSkills && !options.ecazTreachery && !g.nexusCards && !g.discoveryEnabled &&
+ !g.homeworlds && !g.techTokens && !g.strongholdCards,
+ 'Kull preview needs Basic CHOAM and classic factions, CHOAM and Ix decks, and no optional modules.');
+ return initializeKullGameForAudit(g);
+ }
if (options.ecazTreachery && options.leaderSkills) g.ecazTreachery = true;
if (options.leaderSkills) return initializeLeaderSkillsGameForAudit(g);
// Keep this selection visible to its initializer even when another module was
@@ -42295,7 +42304,7 @@ function applyActionInner(
if (t === 'moduleOption') {
requireRule(id === g.host, 'Only the host can change optional rules.');
const option = action.module;
- requireRule(['leaderSkills', 'ecazTreachery', 'nexusCards', 'discoveries'].includes(option as string) &&
+ requireRule(['leaderSkills', 'ecazTreachery', 'nexusCards', 'discoveries', 'kullPreview'].includes(option as string) &&
typeof action.enabled === 'boolean' && Object.keys(action).every(key => ['type', 'module', 'enabled'].includes(key)),
'Choose an optional module to enable or disable.');
if (option === 'nexusCards') g.nexusCards = action.enabled ? { cards: null, phase: null } : null;
@@ -44614,6 +44623,7 @@ export function viewGame(state: Game, id: string, now?: number) {
techTokens: g.techTokens ?? null,
lobbyModules: g.status === 'lobby' ? {
leaderSkills: !!g.lobbyOptions?.leaderSkills, ecazTreachery: !!g.lobbyOptions?.ecazTreachery,
+ kullPreview: !!g.lobbyOptions?.kullPreview,
nexusCards: !!g.nexusCards, discoveries: g.discoveryEnabled === true,
} : null,
ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null,
diff --git a/game/faction-reference.ts b/game/faction-reference.ts
index fcbe43d1..a9671844 100644
--- a/game/faction-reference.ts
+++ b/game/faction-reference.ts
@@ -14,7 +14,7 @@ export const FACTION_RULES: Record<
'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.',
'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.',
'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.',
- 'In the opt-in Kull preview, react to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. See the Kull guide for deferred overbid limits. Full CHOAM starts remain closed.',
+ 'The host can opt into the Basic Kull preview in a CHOAM/classic lobby with the CHOAM and Ix decks and no other modules. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. The preview also withholds some structured Truthtrance questions and defers unfunded non-CHOAM Karama-dependent overbids; see the Kull guide. Complete CHOAM rules remain unfinished.',
],
advanced: [
'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.',
diff --git a/game/reference.ts b/game/reference.ts
index 24a6c117..074c651c 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4755,7 +4755,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Whenever another faction requests a normal revival with a free portion, CHOAM receives a response decision independently of its private hand. Allow the request, or declare La La La for that requester. If the effect succeeds, the whole pending request stops without payment, force movement or quota use; a faction permitted to purchase normal revivals can submit a paid request. If the effect is canceled, the original quote resumes through any Tleilaxu and other applicable responses.',
'La La La does not prevent otherwise legal paid revivals or separate Ghola card effects. With Tleilaxu in the game, Fremen may submit a paid request within their current limit after free revival is prevented. Without Tleilaxu, Fremen’s base purchase restriction leaves no normal force return available. Emperor-funded extras remain separate. The prohibition resets when the next Revival phase begins. Card custody is checked at settlement: if CHOAM cashed in the declared card during the response, no Worthless effect occurs and the interrupted revival resumes.',
'During Shipment and Movement, use Baliset to select another player and a territory CHOAM occupies. The current implementation prevents that player from entering that territory by movement for the phase while CHOAM remains there; shipment is allowed. A declared move into a CHOAM territory pauses for CHOAM regardless of its private hand. Allow it or declare Baliset. A successful effect leaves the moving forces in place and the movement unspent; a canceled or unavailable card resumes the original move after checking its legality again.',
- 'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has a separate opt-in development preview; 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. Normal expansion starts remain disabled.',
+ 'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has an opt-in Basic lobby preview for CHOAM and classic factions with the CHOAM and Ix decks and no other modules; the explicit Nexus Kull profile adds Cunning fuel choices at that same reaction, not in every Nexus game. See both Kull guides for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Complete expansion play remains unfinished.',
],
related: [
'faction-choam',
@@ -4778,7 +4778,7 @@ 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: [
- 'This opt-in development preview supports CHOAM with classic factions in Basic or Advanced, using the physical CHOAM and Ix Treachery decks without optional modules. Basic supports printed ordinary Karama; Advanced also supports Bene Gesserit substitutions and the legal special powers of this roster. Existing games are not retrofitted. Normal expansion starts remain closed, and the visible preview warning is not rules-completion approval.',
+ 'In the lobby, the host can opt into this Basic development preview with CHOAM and classic factions, the CHOAM and Ix Treachery decks, and no other optional modules. It uses printed ordinary Karama. The preview withholds some structured Truthtrance question types and defers unfunded non-CHOAM Karama-dependent bids; leave it off to retain the ordinary current game. A separate Advanced development profile supports Bene Gesserit substitutions and legal special powers. Existing games are not retrofitted, and the warning is not rules-completion approval.',
'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.',
diff --git a/tests/fixture-public-choam-kull.ts b/tests/fixture-public-choam-kull.ts
new file mode 100644
index 00000000..bd215f07
--- /dev/null
+++ b/tests/fixture-public-choam-kull.ts
@@ -0,0 +1,66 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import { actKullPurpose, advanceKullPurpose, kullPurposeClean, kullPurposePlayer, type KullPurposeStep } from './fixture-kull-purpose';
+
+export const act = actKullPurpose;
+export const advance = advanceKullPurpose;
+export const clean = kullPurposeClean;
+export const player = kullPurposePlayer;
+
+/** Treachery cards in the physical zones exercised by this native programme. */
+export function publicKullInventory(g: Game): string[] {
+ const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
+ ...(g.auction?.cards.slice(g.auction.index) ?? []), ...(g.richeseCache ?? [])].map(card => card.id).sort();
+ assert.equal(new Set(cards).size, cards.length, 'Every Treachery card is in one physical zone');
+ return cards;
+}
+
+/** Normal public Basic CHOAM/Emperor/Guild lobby. The host really starts, then
+ * we select only an unopened original starting deal and unopened Spice prefix.
+ * Everything after that uses native setup, auction and movement actions. */
+export function createPublicChoamKullFixture() {
+ let game = createGame('PUBLICKULL', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
+ for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
+ const actions: KullPurposeStep[] = [];
+ const send = (actor: string, action: Action) => { game = act(game,actor,action,actions); };
+ const reach = (predicate: (g: Game) => boolean) => { game = advance(game,predicate,actions); };
+ send(game.host,{type:'moduleOption',module:'kullPreview',enabled:true});
+ for (const p of game.players) send(p.id,{type:'ready'});
+ const lobby = structuredClone(game);
+ send(game.host,{type:'start'});
+ const setup = structuredClone(game);
+ assert.equal(game.status,'setup');
+ assert.equal(game.kullPreview,true,'The public host opt-in enables printed Kull in the supported Basic profile');
+ assert.ok(game.players.every(p => p.hand.length === 0));
+ const originalCards = game.deck.map(card => card.id).sort();
+ const selected: Game['deck'] = [];
+ for (const name of ['kull','karama','karama']) {
+ const i = game.deck.findIndex(card => name === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
+ assert.ok(i >= 0); selected.push(game.deck.splice(i,1)[0]);
+ }
+ const unopenedDeal = selected.map((card,index) => ({player:game.players[index].id,card:card.id}));
+ game.deck.unshift(...selected);
+ const beforeDeal = structuredClone(game);
+ reach(g => g.status === 'playing');
+ const afterSetup = structuredClone(game);
+ const [kull,karama,counter] = selected.map(card => card.id);
+ assert.ok(player(game,'choam').hand.some(card => card.id === kull));
+ assert.ok(player(game,'emperor').hand.some(card => card.id === karama));
+ assert.ok(player(game,'guild').hand.some(card => card.id === counter));
+ assert.deepEqual(publicKullInventory(game),originalCards);
+ const i = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(i >= 0);
+ game.spiceDeck.unshift(game.spiceDeck.splice(i,1)[0]);
+ reach(g => g.phase === 3 && !!g.auction && g.auction.active === 'emperor' && clean(g));
+ const auction = structuredClone(game);
+ assert.deepEqual(publicKullInventory(auction),originalCards);
+ reach(g => g.phase === 5 && g.active === 'choam' && clean(g));
+ send('choam',{type:'endMovement'});
+ reach(g => g.phase === 5 && g.active === 'emperor' && clean(g));
+ assert.deepEqual(publicKullInventory(game),originalCards);
+ return {game,lobby,setup,beforeDeal,afterSetup,auction,actions,originalCards,unopenedDeal,
+ kull,karama,counter,actor:'emperor',reactor:'choam',counterOwner:'guild',
+ staging:'Selected only original unopened starting cards and a safe unopened Spice prefix; all setup, auction passes and turn changes use real native actions.'};
+}
+export type PublicChoamKullFixture = ReturnType;
+export const publicKullAttempt = (f: PublicChoamKullFixture): Action =>
+ ({type:'card',mode:'shipment',card:f.karama,target:f.actor});
diff --git a/tests/public-choam-kull-controls.test.tsx b/tests/public-choam-kull-controls.test.tsx
new file mode 100644
index 00000000..ab555831
--- /dev/null
+++ b/tests/public-choam-kull-controls.test.tsx
@@ -0,0 +1,54 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { viewGame, type GameView } from '../game/engine';
+import { act, createPublicChoamKullFixture as fixture, publicKullAttempt as attempt } from './fixture-public-choam-kull';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url),'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1],match[1]]));
+ return {format:'module',source:`export default ${JSON.stringify(classes)}`,shortCircuit:true};
+ },
+});
+const { GameTable } = await import('../components/game-table');
+const { ChoamKull } = await import('../components/choam-kull');
+aliases.deregister();
+const table = (game: GameView,busy = false) => renderToStaticMarkup(createElement(GameTable,
+ {game,busy,send:async () => {},onExit() {}}));
+const reaction = (game: GameView,busy = false) => renderToStaticMarkup(createElement(ChoamKull,
+ {game,busy,act() {}}));
+
+void test('public host lobby shows the explicitly selected Kull preview and its limits', () => {
+ const f = fixture(), host = viewGame(f.lobby,f.lobby.host), html = table(host);
+ assert.equal(host.lobbyModules?.kullPreview,true);
+ assert.match(html,/Kull Wahad · Basic development preview/);
+ assert.match(html,/does not support structured Truthtrance questions about bids/);
+ assert.match(html,/defers unfunded non-CHOAM Karama-dependent bids/);
+ const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false});
+ const unselected = viewGame(off,off.host);
+ assert.equal(unselected.lobbyModules?.kullPreview,false);
+ assert.match(table(unselected),/Kull Wahad · Basic development preview/);
+ assert.doesNotMatch(table(unselected),/does not support structured Truthtrance questions about bids/);
+ const nonhost = table(viewGame(f.lobby,f.actor));
+ assert.match(nonhost,/disabled=""[^>]*type="checkbox"|type="checkbox"[^>]*disabled=""/);
+});
+
+void test('the real public table exposes Kull use and decline only to the reactor', () => {
+ const f = fixture(), offered = act(f.game,f.actor,attempt(f));
+ const holder = viewGame(offered,f.reactor), other = viewGame(offered,f.actor);
+ assert.match(reaction(holder),/Use Kull Wahad/);
+ assert.match(reaction(holder),/Decline Kull/);
+ assert.match(table(holder),/aria-label="Kull Wahad response"/);
+ assert.match(table(holder),/Use Kull Wahad/);
+ assert.match(reaction(holder,true),/disabled=""[^>]*>Use Kull Wahad/);
+ assert.doesNotMatch(reaction(other),/Use Kull Wahad|Decline Kull|ix-kull-wahad/);
+ assert.match(reaction(other),/Waiting for/);
+});
diff --git a/tests/public-choam-kull-runtime.test.ts b/tests/public-choam-kull-runtime.test.ts
new file mode 100644
index 00000000..2e96502b
--- /dev/null
+++ b/tests/public-choam-kull-runtime.test.ts
@@ -0,0 +1,152 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { bribePromiseModeSupported } from '../game/bribe-promises';
+import { act, advance, clean, createPublicChoamKullFixture as fixture, player,
+ publicKullAttempt as attempt, publicKullInventory as inventory } from './fixture-public-choam-kull';
+
+const held = (g: Game,id: string,card: string) => player(g,id).hand.some(c => c.id === card);
+function reject(g: Game,id: string,action: Action) {
+ const before = structuredClone(g); assert.throws(() => act(g,id,action)); assert.deepEqual(g,before);
+}
+function createLobby(advanced = false, expansions: string[] = ['choam','ix'], last: 'guild'|'ixians' = 'guild') {
+ const game = createGame('KULLLOBBY',newPlayer('choam','CHOAM','choam'),advanced,expansions);
+ joinGame(game,newPlayer('emperor','Emperor','emperor'));
+ joinGame(game,newPlayer(last,last,last));
+ return game;
+}
+function ready(g: Game) {
+ for (const seat of g.players) g = act(g,seat.id,{type:'ready'});
+ return g;
+}
+function declare(g: Game,kull: string) {
+ const reaction = viewGame(g,'choam').kullReaction; assert.ok(reaction);
+ return act(g,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:kull});
+}
+
+void test('Kull is a host-selected lobby preview and default Basic Truth promises retain their existing scope', () => {
+ let lobby = createLobby();
+ assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
+ assert.equal(bribePromiseModeSupported(lobby),true);
+ reject(lobby,'emperor',{type:'moduleOption',module:'kullPreview',enabled:true});
+ reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:'true'});
+ reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true,other:true});
+ const baseline = act(ready(lobby),'choam',{type:'start'});
+ assert.equal(baseline.kullPreview ?? false,false);
+ assert.equal(bribePromiseModeSupported(baseline),true);
+ lobby = ready(lobby);
+ lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
+ assert.ok(lobby.players.every(seat => !seat.ready));
+ assert.equal(lobby.lobbyOptions?.kullPreview,true);
+ lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:false});
+ assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
+ const f = fixture();
+ assert.equal(f.setup.kullPreview,true);
+ assert.equal(bribePromiseModeSupported(f.setup),false,'The opt-in explicitly retains the existing partial promise boundary');
+ assert.ok(f.actions.some(row => row.actor === f.lobby.host && row.action.type === 'start'));
+});
+
+void test('incompatible opt-in starts reject without modifying their original lobby', () => {
+ for (const config of [
+ {advanced:true,expansions:['choam','ix'],last:'guild' as const,module:false},
+ {advanced:false,expansions:['choam'],last:'guild' as const,module:false},
+ {advanced:false,expansions:['choam','ix'],last:'ixians' as const,module:false},
+ {advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true},
+ ]) {
+ let g = createLobby(config.advanced,config.expansions,config.last);
+ g = act(g,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
+ if (config.module) g = act(g,'choam',{type:'moduleOption',module:'leaderSkills',enabled:true});
+ g = ready(g);
+ reject(g,'choam',config.advanced ? {type:'start',advancedPreview:true} : {type:'start'});
+ }
+});
+
+void test('public Kull offer, decline, successful use, and distinct counter retain native custody and income', () => {
+ const f = fixture(), original = structuredClone(f.game), sourceCards = inventory(original);
+ assert.equal(original.kullPreview,true); assert.deepEqual(sourceCards,f.originalCards);
+ const wallet = original.players.map(p => p.spice), forces = original.players.map(p => ({forces:p.forces,reserves:p.reserves,tanks:p.tanks}));
+ const offered = act(original,f.actor,attempt(f));
+ assert.equal(offered.pendingKull?.stage,'offer');
+ assert.equal(offered.karamaShipping,null);
+ assert.ok(held(offered,f.actor,f.karama));
+ assert.deepEqual(offered.players.map(p => p.spice),wallet);
+ assert.deepEqual(offered.players.map(p => ({forces:p.forces,reserves:p.reserves,tanks:p.tanks})),forces);
+ for (const outsider of [f.actor,f.counterOwner]) {
+ const reaction = viewGame(offered,outsider).kullReaction; assert.ok(reaction);
+ assert.equal(reaction.canDecline,false); assert.deepEqual(reaction.plays,[]);
+ }
+ const reaction = viewGame(offered,f.reactor).kullReaction; assert.ok(reaction);
+ assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull && !play.blocked));
+ reject(offered,f.counterOwner,{type:'kullDecision',event:reaction.event,decline:true});
+ reject(offered,f.reactor,{type:'kullDecision',event:'stale',decline:true});
+ const declined = act(offered,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
+ assert.equal(declined.pendingKull,null);
+ assert.equal(declined.karamaShipping?.owner,f.actor);
+ assert.equal(declined.discard.filter(card => card.id === f.karama).length,1);
+ assert.ok(held(declined,f.reactor,f.kull));
+ assert.deepEqual(inventory(declined),sourceCards);
+ const declared = declare(offered,f.kull);
+ const successful = advance(declared,clean);
+ assert.equal(successful.karamaShipping,null);
+ assert.ok(held(successful,f.actor,f.karama));
+ assert.equal(successful.discard.filter(card => card.id === f.kull).length,1);
+ assert.deepEqual(successful.players.map(p => p.spice),wallet);
+ assert.deepEqual(inventory(successful),sourceCards);
+ reject(successful,f.actor,attempt(f));
+ const counter = viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? [];
+ assert.ok(counter.includes(f.counter),'The distinct original Guild Karama is a legal counter');
+ const interrupted = act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
+ const resumed = advance(interrupted,clean);
+ assert.equal(resumed.pendingKull,null);
+ assert.equal(resumed.karamaShipping?.owner,f.actor);
+ assert.ok(held(resumed,f.reactor,f.kull));
+ assert.equal(resumed.discard.filter(card => card.id === f.karama).length,1);
+ assert.equal(resumed.discard.filter(card => card.id === f.counter).length,1);
+ assert.deepEqual(inventory(resumed),sourceCards);
+});
+
+void test('neutral public offer and deferred overbid do not depend on holding Kull', () => {
+ const f = fixture(), absent = structuredClone(f.game), owner = player(absent,f.reactor);
+ // Conserved controlled supplement: swap the original held Kull and a physical
+ // undealt deck card; this is not an earned trade or a new card.
+ const heldIndex = owner.hand.findIndex(card => card.id === f.kull); assert.ok(heldIndex >= 0);
+ assert.ok(absent.deck.length); [owner.hand[heldIndex],absent.deck[0]] = [absent.deck[0],owner.hand[heldIndex]];
+ assert.deepEqual(inventory(absent),f.originalCards);
+ const withCard = act(f.game,f.actor,attempt(f)), withoutCard = act(absent,f.actor,attempt(f));
+ assert.deepEqual(viewGame(withCard,f.actor).kullReaction,viewGame(withoutCard,f.actor).kullReaction);
+ const auctionWithout = structuredClone(f.auction), auctionOwner = player(auctionWithout,f.reactor);
+ const index = auctionOwner.hand.findIndex(card => card.id === f.kull); assert.ok(index >= 0);
+ assert.ok(auctionWithout.deck.length); [auctionOwner.hand[index],auctionWithout.deck[0]] = [auctionWithout.deck[0],auctionOwner.hand[index]];
+ for (const g of [f.auction,auctionWithout]) {
+ const amount = player(g,f.actor).spice + 1;
+ reject(g,f.actor,{type:'bid',amount});
+ assert.notEqual(g.auction?.bidder,f.actor);
+ assert.ok(held(g,f.actor,f.karama));
+ }
+});
+
+void test('all four profiles use only the projected physical Kull cost', () => {
+ const f = fixture(), offered = act(f.game,f.actor,attempt(f));
+ for (const difficulty of DIFFICULTIES) {
+ const v = viewGame(offered,f.reactor); v.players.find(p => p.id === f.reactor)!.bot = difficulty;
+ const action = botActions(v)[0]; assert.ok(action,difficulty);
+ assert.deepEqual(action,{type:'kullDecision',event:v.kullReaction!.event,source:'printed',card:f.kull});
+ const result = act(offered,f.reactor,action); assert.equal(result.pendingKull?.stage,'counter');
+ assert.deepEqual(inventory(result),f.originalCards);
+ }
+});
+
+void test('a legacy unmarked Basic game retains its original Karama path', () => {
+ const f = fixture(), legacy = structuredClone(f.game);
+ // This controlled legacy-shape supplement removes the opt-in stamp only;
+ // the original cards, turn, board and wallets remain from native play.
+ delete legacy.kullPreview;
+ const completed = act(legacy,f.actor,attempt(f));
+ assert.equal(completed.pendingKull ?? null,null);
+ assert.equal(completed.karamaShipping?.owner,f.actor);
+ assert.equal(completed.discard.filter(card => card.id === f.karama).length,1);
+ assert.ok(held(completed,f.reactor,f.kull));
+ assert.deepEqual(inventory(completed),f.originalCards);
+});