Expose Basic Kull preview in lobby
This commit is contained in:
1 parent
2cd2a60a36
commit
92c68ec4f6
11 files changed
+375
-10
No files matched your search
@@ -2203,6 +2203,18 @@ export function GameTable({
|
||||
{label} · preview
|
||||
</label>
|
||||
))}
|
||||
<label className="checkbox-row">
|
||||
<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
|
||||
</label>
|
||||
{g.lobbyModules?.kullPreview && <p className="notice">
|
||||
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.
|
||||
</p>}
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
|
||||
Source audit: 2026-09-06; opt-in preview contract updated 30 September 2026. This is an engineering and product-policy record, not a rules-completion claim. The source audit in [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md) remains applicable. The user-approved counter and BG choices supersede the original proposal; publisher-source uncertainty and full expansion gates remain.
|
||||
|
||||
## Basic public start — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** An explicit lobby opt-in enables the existing printed
|
||||
Kull response for a fresh Basic start with the CHOAM and Ix Treachery decks,
|
||||
native CHOAM plus classic factions, and no optional modules. The default
|
||||
remains off; the separate Nexus Kull fuel source is not enabled. The faction's [publisher rulebook, printed page 7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7)
|
||||
provides the Basic Karama interception; the existing source and selected
|
||||
interruption protocol below remain authoritative for this prototype.
|
||||
|
||||
The supported path retains the user's distinct eligible Karama counter, with
|
||||
the original attempted physical card reserved until the response finishes.
|
||||
The previously selected before-conversion policy remains applicable to its
|
||||
existing supported Advanced preview, which this Basic start does not extend.
|
||||
The user-deferred non-CHOAM winning-overbid interaction keeps its existing
|
||||
private funding fence; this is an unfinished software boundary, not a printed rule.
|
||||
|
||||
The opt-in visibly explains that some existing structured Truthtrance
|
||||
commitments, including ordinary bid, alliance, revival, bribe and combined
|
||||
turn-action questions, are unavailable in this preview. Its deferred
|
||||
unfunded non-CHOAM overbid fence is also disclosed before start. The default retains
|
||||
the existing non-Kull behavior. Existing games are not upgraded, and other
|
||||
factions, optional modules, Advanced starts and Nexus Kull remain under their
|
||||
separate existing profiles. Broader interactions, full Basic/expansion modes,
|
||||
comprehensive assurance, browser and deployed acceptance remain open; this
|
||||
does not open a publication gate.
|
||||
|
||||
The public-start fixture selects original unopened cards and Spice positions,
|
||||
then uses genuine lobby, setup, auction and movement actions. A conserved card
|
||||
swap and an unstamped legacy-shape case are labeled controlled supplements.
|
||||
Ten related files pass 77/77; type/lint and build pass. Server-rendered controls
|
||||
are covered; browser and deployed behavior remain unverified.
|
||||
|
||||
## Advanced effect scope — 18 October 2026
|
||||
|
||||
New Advanced attempts use the supplied rulebook’s specific-effect scope;
|
||||
@@ -78,11 +110,11 @@ The wider publisher cancellation scope across other physical copies or Worthless
|
||||
|
||||
## Opt-in development scope
|
||||
|
||||
The explicit `kull` development profile admits Basic or Advanced CHOAM with classic factions, the physical CHOAM and Ix Treachery decks, and **no optional modules**. It does not introduce Ixian/Tleilaxu factions merely because their physical deck is present. Basic uses ordinary printed Karama; Advanced additionally admits BG substitutions and the existing legal special powers of that roster. `initializeKullGameForAudit` opts the newly initialized Game into `kullPreview`; older saved games and ordinary public starts are not retrofitted.
|
||||
The explicit `kull` development profile admits Basic or Advanced CHOAM with classic factions, the physical CHOAM and Ix Treachery decks, and **no optional modules**. It does not introduce Ixian/Tleilaxu factions merely because their physical deck is present. Basic uses ordinary printed Karama; Advanced additionally admits BG substitutions and the existing legal special powers of that roster. `initializeKullGameForAudit` opts the newly initialized Game into `kullPreview`; the supported fresh Basic lobby opt-in is described above. Older saved games are not retrofitted.
|
||||
|
||||
The separate `nexus-kull` profile admits fresh two-to-six-seat Basic or Advanced native CHOAM plus classic factions, exactly the physical CHOAM/Ix Treachery decks and **Nexus alone**, without physical Sandtrout. `initializeNexusKullGameForAudit` sets `nexusKullPreview` and shared `kullPreview`; it does not retrofit new choices into saved games. Unallied native CHOAM with the actual CHOAM Nexus may choose eligible own canonical Treachery fuel at the pending Karama opportunity. Other expansion factions/modules, Semuta and Richese Betrayal remain excluded; other Nexus previews retain the Kull guard. See the [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary) and [runtime evidence](NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile).
|
||||
|
||||
Both profiles require the visible preview warning and closed public expansion/module gates. The old `kull` profile remains printed-only without Nexus. This bounded Cunning interaction is verified locally, with broader verification **Partial**, not complete CHOAM, full Nexus/expansion certification or deployment evidence. Prior printed-Kull evidence remains scoped to the older preview.
|
||||
Both development profiles retain their preview boundaries and visible warnings; the separate supported Basic lobby opt-in is described above. The old `kull` profile remains printed-only without Nexus. This bounded Cunning interaction is verified locally, with broader verification **Partial**, not complete CHOAM, full Nexus/expansion certification or deployment evidence. Prior printed-Kull evidence remains scoped to the older preview.
|
||||
|
||||
Ordinary cancellation with a live Truthtrance battle or shipment promise must pass the existing pure `assertKaramaPromiseFeasibility` post-cost proof. An unsupported/unprovable suffix is rejected actor-privately before a Kull offer or cost. Proven supported Voice, Prescience, elite, support and aid cancellation paths remain interruptible; no-obligation paths remain unaffected. This is an incomplete preview composition, **not** a publisher prohibition. Do not publicize an unvalidated attempt, release its promise or speculatively execute random effects merely to prove feasibility.
|
||||
|
||||
@@ -194,4 +226,4 @@ Preparation includes public-dispatcher timing gates, with CHOAM's existing phase
|
||||
|
||||
Ten regression cases in `tests/karama-intents.test.ts` cover all variants, serialization, preparation purity/no random draw, stale timing/custody/resources, copied CHOAM selection, exact declaration binding, deferred Harkonnen randomness, injected execution failure isolation, dispatcher parity and preserved CHOAM exceptions. The full 728-case unit suite and 33 persisted/database cases pass.
|
||||
|
||||
The prerequisite alone did not add a pending Kull frame or phase restriction. The later opt-in preview composes these prepared intents with the selected distinct-card/pre-conversion protocol. Full-game, combined-module, deployment and unpayable winning-overbid recovery acceptance are separate; the preview does not reopen public starts.
|
||||
The prerequisite alone did not add a pending Kull frame or phase restriction. The later opt-in preview composes these prepared intents with the selected distinct-card/pre-conversion protocol. Full-game, combined-module, deployment and unpayable winning-overbid recovery acceptance are separate; the later bounded Basic lobby opt-in is described above.
|
||||
@@ -20,6 +20,17 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Printed Kull in supported Basic starts — 27 October. Prototyped / Partial.**
|
||||
An explicit lobby opt-in enables the printed Kull reaction for fresh Basic
|
||||
CHOAM/classic games using both CHOAM and Ix Treachery decks and no optional
|
||||
modules. The preview visibly discloses that some structured Truthtrance
|
||||
commitment families are unavailable and that unfunded non-CHOAM Karama-dependent
|
||||
overbids remain fenced under the user-deferred ruling. The default retains
|
||||
existing behavior; the selection does not add Nexus fuel or retrofit games.
|
||||
Other rosters, modules, Advanced starts and broader acceptance remain open.
|
||||
Ten related files pass 77/77; type/lint and build pass.
|
||||
[Scope and source](CHOAM_KULL_DESIGN.md#basic-public-start--27-october-2026).
|
||||
|
||||
**Upcoming Basic Charity Truthtrance — 27 October. Prototyped / Partial.**
|
||||
The Charity question also accepts supported quiet Storm and Spice Blow windows
|
||||
for the upcoming phase in the same turn. Its private quote uses the known purse
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Printed Kull in supported Basic starts
|
||||
|
||||
Offer an explicit lobby opt-in for the existing printed Kull response in a
|
||||
fresh Basic start with native CHOAM and classic factions, both CHOAM and Ix
|
||||
Treachery decks, and no optional modules. The default remains off because the
|
||||
preview currently excludes some structured Truthtrance commitment families;
|
||||
the lobby explains that limitation and the user-deferred unfunded non-CHOAM
|
||||
Karama winning-overbid fence before start. Preserve the selected distinct eligible
|
||||
counter and original attempted-card custody. This does not enable Nexus Kull,
|
||||
change old games, or certify other faction/module compositions. The publisher
|
||||
Basic rule supports the core reaction; interruption details remain the recorded
|
||||
user-selected policy. Full modes, browser, deployed and publication acceptance
|
||||
remain open. Ten related files pass 77/77; type/lint and build pass. Private
|
||||
proof is `20261027-public-kull-optin-proof-v3/`.
|
||||
[Contract](CHOAM_KULL_DESIGN.md#basic-public-start--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Upcoming Basic Charity Truthtrance
|
||||
|
||||
Extend the provisional Charity question to supported quiet Basic Storm and Spice
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Printed Kull in supported Basic starts — 27 October 2026
|
||||
|
||||
Offer an explicit lobby opt-in for the existing printed Kull response in fresh
|
||||
Basic CHOAM/classic starts with the CHOAM and Ix decks and no optional modules.
|
||||
The default remains off; the preview visibly discloses unavailable structured
|
||||
Truthtrance commitment families and the user-deferred unfunded non-CHOAM winning-overbid
|
||||
fence. Retain the selected distinct counter and reserved original attempted
|
||||
card. The publisher's Basic rule establishes the reaction, while the nested
|
||||
protocol and fence retain their recorded policy status. Nexus fuel, other
|
||||
compositions and existing saved games remain under their existing boundaries.
|
||||
[Source and boundaries](CHOAM_KULL_DESIGN.md#basic-public-start--27-october-2026).
|
||||
|
||||
## Upcoming Basic Charity Truthtrance — 27 October 2026
|
||||
|
||||
Provisionally allow the same Charity question in supported quiet Basic Storm and
|
||||
|
||||
+14
-4
@@ -9997,16 +9997,16 @@ function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
|
||||
return lostCyborgs;
|
||||
}
|
||||
/** Lobby-only selections whose saved setup shape is created at start. */
|
||||
type LobbyOptions = { leaderSkills?: true; ecazTreachery?: true };
|
||||
type LobbyOptions = { leaderSkills?: true; ecazTreachery?: true; kullPreview?: true };
|
||||
function lobbyOptionsIntegrity(g: Game) {
|
||||
const options = g.lobbyOptions;
|
||||
if (options === undefined) return;
|
||||
requireRule(g.status === 'lobby' && !!options && typeof options === 'object' && !Array.isArray(options) &&
|
||||
Object.entries(options).every(([key, value]) => ['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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+2
-2
@@ -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.',
|
||||
|
||||
@@ -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<typeof createPublicChoamKullFixture>;
|
||||
export const publicKullAttempt = (f: PublicChoamKullFixture): Action =>
|
||||
({type:'card',mode:'shipment',card:f.karama,target:f.actor});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user