Expose Advanced printed Kull preview in lobbies

This commit is contained in:
Quintine committed 2026-10-27 22:05:43 +11:00
1 parent f1bef950e0
commit b750a3d3eb
17 files changed
+422 -20

No files matched your search

+15 -8
View File
@@ -1360,6 +1360,7 @@ export function GameTable({
<p className="notice" role="status">
Kull Wahad development preview · provisional interception timing, not
complete CHOAM or certified expansion rules.{' '}
{g.advanced && 'Advanced Kull prevents the attempted specific Karama effect; its phase duration is provisional. '}
<a href="/rules?topic=choam-kull#choam-kull">Preview rules and limits</a>
</p>
)}
@@ -2207,15 +2208,21 @@ export function GameTable({
<input type="checkbox" checked={!!g.lobbyModules?.kullPreview}
disabled={busy || g.host !== me.id}
onChange={(e) => act({ type: 'moduleOption', module: 'kullPreview', enabled: e.target.checked })} />
Kull Wahad · Basic development preview
Kull Wahad · {g.advanced ? 'Advanced' : 'Basic'} development preview
</label>
{g.lobbyModules?.kullPreview && <p className="notice">
Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules.
This preview does not support phase-wide bid or combined turn-action Truthtrance questions, and it defers
unfunded non-CHOAM Karama-dependent bids. It does not represent complete
CHOAM rules. Every player must ready again after changing it.
When Richese is seated, structured bid, alliance, Revival and bribe commitments are unavailable,
and some Kull reactions may be unavailable in complex response windows.
{g.advanced ? <>
Requires Advanced CHOAM with only classic factions, the CHOAM and Ix decks, and no other optional modules.
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.
Phase-wide bid and combined turn-action Truthtrance questions are unavailable.
When Richese is seated, structured bid, alliance, Revival and bribe commitments are also unavailable,
and 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.
Every player must ready again after changing it.
</p>}
{g.lobbyModules?.kullDefault && <p className="notice">
{g.lobbyModules.kullPreview
@@ -2225,7 +2232,7 @@ export function GameTable({
{!g.lobbyModules?.kullDefault && !g.lobbyModules?.kullPreview &&
g.expansions.includes('choam') && g.expansions.includes('ix') &&
g.players.some(player => player.faction === 'choam') && <p className="notice">
Printed Kull is inactive in this lobby. Its preview requires the supported Basic configuration and may need different module selections.
Printed Kull is inactive in this lobby. Its preview requires a supported {g.advanced ? 'Advanced' : 'Basic'} configuration and may need different module selections.
</p>}
<label className="checkbox-row">
<input
+1 -1
View File
@@ -11,7 +11,7 @@ export function AdvancedPreviewNotice({
being completed.{' '}
{compact
? 'Some play may need fixes.'
: 'Use the six classic factions; expansion games are not available. Tech Tokens and Stronghold Cards can be enabled in the lobby.'}
: 'Classic and supported expansion selections are unfinished previews. Some faction and module combinations are not yet available. Tech Tokens and Stronghold Cards can be selected in the lobby where supported.'}
</p>
);
}
+4
View File
@@ -13,6 +13,10 @@ the rules-completion or publication criteria.
**Discoveries**, **Homeworlds**, **Tech Tokens** (three or more players) or
**Stronghold Cards**. Supported module/roster combinations use their existing
setup; unsupported selections reject at start with a reason.
With native CHOAM, classic factions and exactly the CHOAM and Ix decks, the
host may instead select the unfinished printed-Kull preview without other
optional modules. Its effect-specific duration and combination limits are
shown in the lobby and remain provisional.
4. Each human chooses **Ready for Advanced preview**. The host chooses
**Begin Advanced preview**.
+17
View File
@@ -1,5 +1,22 @@
# Kull Wahad: source limits and Karama interruption design
## Advanced printed Kull in the lobby — 27 October 2026
**Prototyped / Partial.** The host can explicitly select the existing printed
Kull initializer in an Advanced preview lobby with native CHOAM, classic
factions and exactly the CHOAM and Ix decks, without optional modules. The
normal explicit Advanced-preview start remains required. It uses genuine setup
and the existing private Kull, Karama and distinct-counter controls; it does
not retrofit existing games or expand the supported roster. Structured bid,
alliance, Revival, bribe and combined turn-action questions and unfunded
non-CHOAM Karama-dependent overbids remain outside this preview.
The authorized supplied rulebook, physical p. 29, specifies the Advanced
specific-effect restriction. Phase expiry, the before-conversion timing and
distinct counter remain the separately documented provisional/selected
procedures. This exposes a first version, not a completed expansion or release
gate. [Real-lobby evidence](../tests/advanced-kull-lobby-runtime.test.ts).
## Fresh Basic lobby default — 27 October 2026
**Prototyped / Partial.** New lobbies automatically select the existing
+8
View File
@@ -20,6 +20,14 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Advanced printed Kull lobby selection — 27 October. Prototyped / Partial.**
The host can explicitly select the existing Advanced printed-Kull preview for
CHOAM and classic factions with the CHOAM and Ix decks and no optional modules.
The Advanced acknowledgement, physical interception and counters remain native;
effect-specific phase expiry is provisional. Structured-question and overbid
limits, wider combinations and full rules/deployed acceptance remain open.
[Scope](CHOAM_KULL_DESIGN.md#advanced-printed-kull-in-the-lobby--27-october-2026).
**Sabotage gift at the hand limit — 27 October. Prototyped / Partial.**
Moritani can give a card to a full-handed entrant, who provisionally chooses one
held card to discard before the original entry continues. The private choice,
+9
View File
@@ -1,5 +1,14 @@
# Dune implementation status
## 27 October 2026 — Advanced printed Kull lobby selection
The host can explicitly select the existing Advanced printed-Kull prototype
for CHOAM and classic seats with the CHOAM and Ix decks, without optional
modules. The normal Advanced preview acknowledgement remains required;
effect-specific restriction and counters use the existing implementation.
Broader rules and deployed acceptance remain open.
[Contract](CHOAM_KULL_DESIGN.md#advanced-printed-kull-in-the-lobby--27-october-2026).
## 27 October 2026 — Sabotage gift at the hand limit
When Moritani gives a card to a full-handed entrant after Sabotage, the
+5 -3
View File
@@ -1,6 +1,7 @@
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift';
import { 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,
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
@@ -10117,15 +10118,16 @@ function startModules(state: Game): Game {
const g = structuredClone(state);
delete g.lobbyOptions;
if (lobbyKullEnabled(state)) {
requireRule(g.advanced === false && (g.expansions.length === 2 ||
const supportedBasic = g.advanced === false && (g.expansions.length === 2 ||
g.expansions.length === 3 && g.expansions.includes('ecaz')) && g.expansions.includes('choam') &&
g.expansions.includes('ix') && !!byFaction(g,'choam') &&
g.players.every(player => player.faction === 'choam' || player.faction === 'richese' || player.faction === 'ixians' ||
player.faction === 'tleilaxu' || g.expansions.includes('ecaz') &&
(player.faction === 'ecaz' || player.faction === 'moritani') || CLASSIC_FACTIONS[player.faction] === true) &&
!options.leaderSkills && !options.ecazTreachery && !g.nexusCards && !g.discoveryEnabled &&
!g.homeworlds && !g.techTokens && !g.strongholdCards,
'Kull preview needs Basic CHOAM with supported factions, CHOAM and Ix decks, each faction’s deck, and no optional modules.');
!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.');
return initializeKullGameForAudit(g);
}
if (options.ecazTreachery && options.leaderSkills) g.ecazTreachery = true;
+1 -1
View File
@@ -18,7 +18,7 @@ export const FACTION_RULES: Record<
],
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.',
'The Kull preview also intercepts supported special Karama intents before once-use or other costs, and BG Worthless substitutions before conversion. Successful Kull leaves an attempted special unspent and the original BG card held; ordinary later cancellation of BG conversion still discards its card. Approved preview timing is product policy, not publisher clarification, and does not enable Nexus Cunning Kull or combined modules.',
'In a supported Advanced CHOAM and classic-faction preview lobby, the host can select printed Kull with the CHOAM and Ix decks and no optional modules. It intercepts supported special Karama intents before once-use or other costs, and BG Worthless substitutions before conversion. Successful Kull leaves an attempted special unspent and the original BG card held; ordinary later cancellation of BG conversion still discards its card. Advanced restricts the attempted effect; phase expiry is provisional. This preview does not enable Nexus Cunning Kull or combined modules.',
'Reserve spice to pay for some or all of your ally’s battle support. Each sealed plan records its own and allied shares. A Karama response may prevent this alliance benefit for one battle; unused funding returns at the end of Battle.',
'Receive half of each other player’s actual force-support payment, rounded down, unless any traitor is revealed. Your own payments, including support paid for your ally, go to the bank. A separate Karama response can cancel this battle’s income. See CHOAM combat for examples and outstanding interaction audits.',
'The Auditor is an additional strength-two leader. After using it in battle, CHOAM may inspect two randomly selected unused opposing Treachery Cards if it survives, or one if it dies, limited by the unused cards available. The opponent may pay CHOAM one spice per card that would be inspected to prevent the whole inspection; Karama can also cancel the power. Use the Auditor guide for the exact private choices and limits. Setup and inspection are supported in development games; complete combined play remains unfinished.',
+34
View File
@@ -0,0 +1,34 @@
import type { KullDefaultProfile, KullDefaultEligibility } from './kull-default-profile';
const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']);
const blocked = (reason: string): KullDefaultEligibility => ({ eligible: false, reason });
/** Public configuration for the existing Advanced printed-Kull prototype.
* This does not select it, inspect private hands, validate fresh inventory, or
* authorize a start. In particular, the genuine initializer remains the owner
* of the saved-game, physical deck and readiness checks. */
export function advancedKullLobbyProfile(game: KullDefaultProfile): KullDefaultEligibility {
if (game.status !== 'lobby') return blocked('Advanced Kull applies only to a fresh lobby.');
if (game.advanced !== true) return blocked('Choose Advanced preview rules for this Kull profile.');
if (!Array.isArray(game.expansions) || game.expansions.length !== 2 ||
!game.expansions.includes('choam') || !game.expansions.includes('ix'))
return blocked('Advanced Kull currently needs exactly the CHOAM and Ix decks.');
if (!Array.isArray(game.players) || game.players.length < 1 || game.players.length > 6 ||
!game.players.some(player => player?.faction === 'choam') ||
!game.players.every(player => !!player && typeof player.faction === 'string' &&
(player.faction === 'choam' || classic.has(player.faction))) ||
new Set(game.players.map(player => player.faction)).size !== game.players.length)
return blocked('Advanced Kull currently needs CHOAM and up to five distinct classic factions.');
const options = game.lobbyOptions, projected = game.lobbyModules;
if (options?.leaderSkills || options?.ecazTreachery || projected?.leaderSkills || projected?.ecazTreachery ||
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('Advanced Kull currently excludes optional modules.');
return { eligible: true, reason: null };
}
/** Eligibility remains independent of any host selection or opt-out. */
export function advancedKullLobbyEligible(game: KullDefaultProfile): boolean {
return advancedKullLobbyProfile(game).eligible;
}
+1 -1
View File
@@ -4782,7 +4782,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: [
'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. The supported roster includes Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. It uses printed ordinary Karama. The preview withholds some structured questions and defers unfunded non-CHOAM Karama-dependent bids. A separate Advanced development profile supports Bene Gesserit substitutions and legal special powers. Existing lobbies and 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 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 the supported Basic printed Kull preview without Richese, existing current-lot ordinary bid, Revival, alliance and bribe Truthtrance questions are available, including supported grouped and mixed forms. A pending Kull response holds their controls while the original transaction resolves. The existing Richese Semuta preview does not yet admit these structured commitments; phase-wide bid and combined turn-action questions and 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.',
@@ -0,0 +1,87 @@
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 { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
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');
aliases.deregister();
function lobby(): Game {
const game = createGame('ADVKULLUI', newPlayer('choam', 'CHOAM', 'choam'), true, ['choam', 'ix']);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
return game;
}
const table = (game: GameView, busy = false) => renderToStaticMarkup(createElement(GameTable,
{ game, busy, send: async () => {}, onExit() {} }));
/** The precise checkbox's ownership state, rather than any other lobby control. */
function kullLabel(html: string): string {
const label = html.match(/<label\b[^>]*>(?:(?!<\/label>)[\s\S])*Kull Wahad(?:(?!<\/label>)[\s\S])*<\/label>/)?.[0];
assert.ok(label, 'The Kull preview choice is present in the lobby');
return label;
}
void test('Advanced Kull is off until the host selects the preview and displays its scope and limits', () => {
const fresh = lobby(), host = viewGame(fresh, 'choam');
assert.equal(host.lobbyModules?.kullPreview, false);
assert.equal(host.lobbyModules?.kullDefault, false);
const unchecked = table(host);
assert.match(kullLabel(unchecked), /type="checkbox"/);
assert.match(unchecked, /Kull Wahad ·/);
assert.match(unchecked, /Advanced.*development preview/);
assert.doesNotMatch(kullLabel(unchecked), /\bchecked=/);
assert.doesNotMatch(kullLabel(unchecked), /\bdisabled=/);
assert.match(unchecked, /Printed Kull is inactive in this lobby/);
const selected = applyAction(fresh, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
const selectedView = viewGame(selected, 'choam'), selectedHtml = table(selectedView);
assert.equal(selectedView.lobbyModules?.kullPreview, true);
assert.match(kullLabel(selectedHtml), /\bchecked=/);
assert.match(selectedHtml, /Requires Advanced CHOAM with only classic factions/);
assert.match(selectedHtml, /prevents the attempted specific Karama effect/);
assert.match(selectedHtml, /phase duration is provisional/);
assert.match(selectedHtml, /Structured bid, alliance, Revival, bribe and combined turn-action questions are unavailable/);
assert.match(selectedHtml, /Unfunded non-CHOAM Karama-dependent bids remain deferred/);
assert.doesNotMatch(selectedHtml, /Printed Kull is inactive in this lobby/);
});
void test('only the host can change the actual Advanced Kull checkbox', () => {
const selected = applyAction(lobby(), 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
const host = kullLabel(table(viewGame(selected, 'choam')));
const guest = kullLabel(table(viewGame(selected, 'emperor')));
assert.doesNotMatch(host, /\bdisabled=/);
assert.match(guest, /\bdisabled=/);
assert.match(kullLabel(table(viewGame(selected, 'choam'), true)), /\bdisabled=/);
assert.throws(() => applyAction(selected, 'emperor', { type: 'moduleOption', module: 'kullPreview', enabled: false }));
});
void test('a real Advanced Kull start retains both the Advanced and Kull preview notices', () => {
let game = applyAction(lobby(), 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
game = applyAction(game, 'choam', { type: 'start', advancedPreview: true });
assert.equal(game.status, 'setup');
assert.equal(game.kullPreview, true);
for (const actor of ['choam', 'emperor']) {
const html = table(viewGame(game, actor));
assert.match(html, /Advanced preview/);
assert.match(html, /Rules and card interactions are still/);
assert.match(html, /Kull Wahad development preview/);
assert.match(html, /provisional interception timing/);
assert.match(html, /not\s+complete CHOAM or certified expansion rules/);
}
});
+110
View File
@@ -0,0 +1,110 @@
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 { act, advance, clean, player, inventory, createAdvancedKullLobbyFixture as fixture,
advancedKullAttempt as attempt } from './fixture-advanced-kull-lobby';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
function ready(game: Game): Game {
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
return game;
}
function lobby(roster: readonly ('atreides' | 'beneGesserit' | 'emperor' | 'fremen' | 'guild' | 'harkonnen')[],
advanced = true, expansions = ['choam', 'ix']): Game {
const game = createGame('ADVKULL', newPlayer('choam', 'CHOAM', 'choam'), advanced, expansions);
for (const faction of roster) joinGame(game, newPlayer(faction, faction, faction));
return game;
}
void test('a normal Advanced lobby requires the explicit host selection and preview-start acknowledgement', () => {
let game = lobby(['emperor', 'guild']);
assert.equal(viewGame(game, 'choam').lobbyModules?.kullPreview, false);
rejected(game, 'emperor', { type: 'moduleOption', module: 'kullPreview', enabled: true });
game = act(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
assert.equal(viewGame(game, 'choam').lobbyModules?.kullPreview, true);
game = ready(game);
rejected(game, 'choam', { type: 'start' });
const started = act(game, 'choam', { type: 'start', advancedPreview: true });
assert.equal(started.status, 'setup');
assert.equal(started.advanced, true);
assert.equal(started.kullPreview, true);
assert.equal(viewGame(started, 'choam').kullPreview, true);
assert.equal(started.deck.filter(card => card.id === 'ix-kull-wahad').length, 1);
});
for (const seats of [2, 3, 4, 5, 6] as const) {
void test(`Advanced Kull public preview starts its native CHOAM and ${seats - 1} classic-seat roster`, () => {
const others = ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild'] as const;
let game = lobby(others.slice(0, seats - 1));
game = act(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
game = act(ready(game), 'choam', { type: 'start', advancedPreview: true });
assert.equal(game.status, 'setup');
assert.equal(game.players.length, seats);
assert.equal(game.kullPreview, true);
assert.equal(game.deck.filter(card => card.id === 'ix-kull-wahad').length, 1);
});
}
void test('unselected and saved pre-selection Advanced lobbies do not acquire Kull automatically', () => {
for (const old of [false, true]) {
const game = lobby(['emperor']);
if (old) delete game.kullDefaultPolicy;
const started = act(ready(game), 'choam', { type: 'start', advancedPreview: true });
assert.equal(started.kullPreview ?? false, false);
assert.equal(viewGame(started, 'choam').kullPreview, false);
}
});
void test('Advanced Kull selection rejects unsupported decks, native factions and modules atomically', () => {
for (const kind of ['missingIx', 'richese', 'nexus', 'skills'] as const) {
let game = lobby(['emperor'], true, kind === 'missingIx' ? ['choam'] : ['choam', 'ix']);
if (kind === 'richese') joinGame(game, newPlayer('richese', 'Richese', 'richese'));
if (kind === 'nexus') game = act(game, 'choam', { type: 'moduleOption', module: 'nexusCards', enabled: true });
if (kind === 'skills') game = act(game, 'choam', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
game = act(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
game = ready(game);
rejected(game, 'choam', { type: 'start', advancedPreview: true });
}
});
void test('normal Advanced Kull preview uses the real held card and distinct counter at the original attempt', () => {
const f = fixture();
assert.ok(f.actions.some(row => row.actor === f.reactor && row.action.type === 'start' && row.action.advancedPreview === true));
const pending = act(f.game, f.actor, attempt(f));
assert.equal(pending.pendingKull?.stage, 'offer');
assert.ok(player(pending, f.actor).hand.some(card => card.id === f.karama));
const reaction = viewGame(pending, f.reactor).kullReaction; assert.ok(reaction);
assert.equal(reaction.scope, 'effect');
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull && !play.blocked));
assert.deepEqual(viewGame(pending, f.actor).kullReaction?.plays, []);
assert.deepEqual(inventory(pending), f.originalCards);
const bot = viewGame(pending, f.reactor); bot.players.find(seat => seat.id === f.reactor)!.bot = 'Easy';
const botAction = botActions(bot).find(action => action.type === 'kullDecision'); assert.ok(botAction);
assert.doesNotThrow(() => act(structuredClone(pending), f.reactor, botAction));
const declared = act(pending, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
assert.equal(declared.pendingKull?.stage, 'counter');
assert.ok(viewGame(declared, f.counterOwner).responseControls?.cancelCards.includes(f.counter));
const countered = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
const resumed = advance(countered, clean);
assert.equal(resumed.karamaShipping?.owner, f.actor);
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.ok(player(resumed, f.reactor).hand.some(card => card.id === f.kull));
assert.deepEqual(inventory(resumed), f.originalCards);
const successful = advance(declared, clean);
assert.equal(successful.karamaShipping, null);
assert.ok(player(successful, f.actor).hand.some(card => card.id === f.karama));
assert.equal(successful.discard.filter(card => card.id === f.kull).length, 1);
assert.equal(viewGame(successful, f.actor).karamaBlocked, null);
assert.equal(viewGame(successful, f.actor).karamaPurposeBlocks[0]?.purpose, 'shipment-price');
rejected(successful, f.actor, attempt(f));
assert.deepEqual(inventory(successful), f.originalCards);
});
+1 -1
View File
@@ -22,7 +22,7 @@ void test('rules controls identify the unfinished selection before readiness and
const preview = render(true, true);
assert.match(preview, /value="advanced" selected=""/);
assert.match(preview, /Rules and card interactions are still/);
assert.match(preview, /six classic factions/);
assert.match(preview, /Classic and supported expansion selections are unfinished previews/);
assert.match(preview, /Changing rules clears player readiness/);
assert.match(preview, /Switching to Basic removes Stronghold Cards/);
assert.match(render(true, false), /<select disabled=""/);
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { actKullPurpose as act, advanceKullPurpose as advance, kullPurposeClean as clean,
kullPurposePlayer as player, type KullPurposeStep } from './fixture-kull-purpose';
import { publicKullInventory as inventory } from './fixture-public-choam-kull';
export { act, advance, clean, player, inventory };
/** Genuine public Advanced preview entry with an explicit host Kull selection.
* The original unopened starting-deal and two Spice positions are selected;
* setup, paid shipment and subsequent decisions remain actual native actions. */
export function createAdvancedKullLobbyFixture() {
let game = createGame('ADVANCEDKULL', newPlayer('choam', 'CHOAM', 'choam'), true, ['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 = (until: (g: Game) => boolean) => { game = advance(game, until, actions); };
send('choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
for (const seat of game.players) send(seat.id, { type: 'ready' });
const lobby = structuredClone(game);
send('choam', { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup');
assert.equal(game.advanced, true);
assert.equal(game.kullPreview, true);
assert.ok(game.players.every(seat => seat.hand.length === 0));
const originalCards = game.deck.map(card => card.id).sort();
const selected: Game['deck'] = [];
for (const kind of ['kull', 'karama', 'karama'] as const) {
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 beforeDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
const [kull, karama, counter] = selected.map(card => card.id);
for (const [seat, card] of [['choam', kull], ['emperor', karama], ['guild', counter]] as const)
assert.ok(player(game, seat).hand.some(held => held.id === card));
assert.deepEqual(inventory(game), originalCards);
const safe: Game['spiceDeck'] = [];
for (let i = 0; i < 2; i++) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(index >= 0); safe.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...safe);
reach(state => state.phase === 5 && state.active === 'choam' && clean(state));
send('choam', { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === 'emperor' && clean(state));
assert.deepEqual(inventory(game), originalCards);
return { game, lobby, setup, beforeDeal, afterSetup, actions, originalCards,
kull, karama, counter, actor: 'emperor', reactor: 'choam', counterOwner: 'guild' };
}
export type AdvancedKullLobbyFixture = ReturnType<typeof createAdvancedKullLobbyFixture>;
export const advancedKullAttempt = (f: AdvancedKullLobbyFixture): Action =>
({ type: 'card', mode: 'shipment', card: f.karama, target: f.actor });
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { advancedKullLobbyEligible, advancedKullLobbyProfile } from '../game/kull-advanced-profile';
import { kullDefaultEligible, type KullDefaultProfile } from '../game/kull-default-profile';
const lobby: KullDefaultProfile = {
status: 'lobby', advanced: true, expansions: ['choam', 'ix'],
players: [{ faction: 'choam' }, { faction: 'atreides' }],
};
void test('Advanced printed Kull supports native CHOAM plus classic factions with the selected decks', () => {
assert.deepEqual(advancedKullLobbyProfile(lobby), { eligible: true, reason: null });
assert.equal(advancedKullLobbyEligible({ ...lobby, players: [{ faction: 'choam' }] }), true,
'The host can see the preview before another seat joins; the start checks its minimum.');
for (const faction of ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'])
assert.equal(advancedKullLobbyEligible({ ...lobby, players: [{ faction: 'choam' }, { faction }] }), true, faction);
assert.equal(advancedKullLobbyEligible({ ...lobby, expansions: ['ix', 'choam'] }), true);
});
void test('Advanced profile excludes ongoing games, wrong rules, unsupported decks and native expansion seats', () => {
for (const profile of [
{ ...lobby, status: 'playing' }, { ...lobby, status: 'setup' }, { ...lobby, advanced: false },
{ ...lobby, players: [{ faction: 'emperor' }, { faction: 'guild' }] },
{ ...lobby, expansions: ['choam'] }, { ...lobby, expansions: ['ix'] },
{ ...lobby, expansions: ['choam', 'ix', 'ecaz'] }, { ...lobby, expansions: ['choam', 'choam'] },
{ ...lobby, players: [] }, { ...lobby, players: [{ faction: 'choam' }, { faction: 'choam' }] },
...['richese', 'ixians', 'tleilaxu', 'ecaz', 'moritani', 'unknown'].map(faction =>
({ ...lobby, players: [{ faction: 'choam' }, { faction }] })),
{ ...lobby, players: ['choam', 'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']
.map(faction => ({ faction })) },
]) {
const result = advancedKullLobbyProfile(profile);
assert.equal(result.eligible, false); assert.ok(result.reason);
}
});
void test('Advanced lobby profile excludes all optional modules on source and public projection', () => {
for (const option of [
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
{ lobbyModules: { leaderSkills: true } }, { lobbyModules: { ecazTreachery: true } },
{ lobbyModules: { nexusCards: true } }, { lobbyModules: { discoveries: true } },
{ leaderSkills: {} }, { ecazTreachery: true }, { nexusCards: {} }, { discoveryEnabled: true },
{ discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} },
{ homeworlds: {} }, { techTokens: {} }, { strongholdCards: {} },
]) assert.equal(advancedKullLobbyEligible({ ...lobby, ...option }), false);
assert.equal(advancedKullLobbyEligible({ ...lobby,
lobbyModules: { leaderSkills: false, ecazTreachery: false, nexusCards: false, discoveries: false },
lobbyOptions: { leaderSkills: false, ecazTreachery: false } }), true);
});
void test('Advanced eligibility does not alter Basic eligibility or depend on a host opt-out', () => {
const basic = { ...lobby, advanced: false };
const selection = { leaderSkills: false, kullOptOut: true };
assert.equal(kullDefaultEligible(basic), true);
assert.equal(advancedKullLobbyEligible(basic), false);
assert.equal(advancedKullLobbyEligible({ ...lobby, lobbyOptions: selection }), true);
});
void test('Advanced eligibility reads only public faction/configuration and never private hands or spice', () => {
const choam = { faction: 'choam', get hand() { throw Error('private hand read'); },
get spice() { throw Error('private spice read'); } };
const rival = { faction: 'beneGesserit', get hand() { throw Error('private rival hand read'); } };
const game = { ...lobby, players: [choam, rival] };
assert.equal(advancedKullLobbyEligible(game), true);
assert.deepEqual(game.expansions, ['choam', 'ix']);
assert.equal(game.status, 'lobby');
});
+4 -4
View File
@@ -31,15 +31,15 @@ void test('public host lobby shows the explicitly selected Kull preview and its
assert.equal(host.lobbyModules?.kullPreview,true);
assert.match(html,/Kull Wahad · Basic development preview/);
assert.match(html,/Printed Kull is enabled by default/);
assert.match(html,/does not support phase-wide bid or combined turn-action Truthtrance questions/);
assert.match(html,/defers unfunded non-CHOAM Karama-dependent bids/);
assert.match(html,/When Richese is seated, structured bid, alliance, Revival and bribe commitments are unavailable/);
assert.match(html,/Phase-wide bid and combined turn-action Truthtrance questions are unavailable/);
assert.match(html,/Unfunded non-CHOAM Karama-dependent bids remain deferred/);
assert.match(html,/When Richese is seated, structured bid, alliance, Revival and bribe commitments are also unavailable/);
const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false});
const unselected = viewGame(off,off.host);
assert.equal(unselected.lobbyModules?.kullPreview,false);
assert.match(table(unselected),/Kull Wahad · Basic development preview/);
assert.match(table(unselected),/The host has turned off the printed Kull preview/);
assert.doesNotMatch(table(unselected),/does not support phase-wide bid or combined turn-action Truthtrance questions/);
assert.doesNotMatch(table(unselected),/Phase-wide bid and combined turn-action Truthtrance questions are unavailable/);
const nonhost = table(viewGame(f.lobby,f.actor));
assert.match(nonhost,/disabled=""[^>]*type="checkbox"|type="checkbox"[^>]*disabled=""/);
});
+1 -1
View File
@@ -51,7 +51,7 @@ void test('Kull is a host-configurable Basic lobby preview with supported Truth
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:true,expansions:['choam','ix'],last:'guild' as const,module:true},
{advanced:false,expansions:['choam'],last:'guild' as const,module:false},
{advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true},
]) {