Expose Advanced printed Kull preview in lobbies
This commit is contained in:
1 parent
f1bef950e0
commit
b750a3d3eb
17 files changed
+422
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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**.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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=""/);
|
||||
|
||||
@@ -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 });
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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=""/);
|
||||
});
|
||||
|
||||
@@ -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},
|
||||
]) {
|
||||
|
||||
Reference in new issue
Block a user