Support native Truthtrance questions with Basic Kull

This commit is contained in:
Quintine committed 2026-10-27 21:39:42 +11:00
1 parent 1059cc1682
commit f96183f350
15 files changed
+443 -26

No files matched your search

+4 -3
View File
@@ -2211,10 +2211,11 @@ export function GameTable({
</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 structured Truthtrance questions about bids, alliances, Revival,
or combined turn actions, and it defers unfunded non-CHOAM Karama-dependent bids. It does not represent complete
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 bribe commitments are also unavailable, and some Kull reactions may be unavailable in complex response windows.
When Richese is seated, structured bid, alliance, Revival and bribe commitments are unavailable,
and some Kull reactions may be unavailable in complex response windows.
</p>}
<label className="checkbox-row">
<input
+24
View File
@@ -1,5 +1,29 @@
# Kull Wahad: source limits and Karama interruption design
## Basic Kull and native Truthtrance questions — 27 October 2026
**Prototyped / Partial.** The existing opt-in Basic printed-Kull profile
without Richese also admits the previously implemented current-lot ordinary
bid, ordinary Revival and ordinary Nexus alliance question families. The same
native answer, payment, revival and alliance controls apply. A pending Kull
offer or counter holds question controls; it does not itself release an answer.
The original Karama purchase cannot be attempted to evade one's own Yes bid.
When an opposing purchase is allowed to close the lot, an affected Yes may be
released; successful Kull leaves the original bid available. A real auction
payment's interruption can similarly change the funds available for a Revival
answer; a distinct counter resumes the original payment.
This uses the existing provisional Kull timing, not a new publisher ruling.
The earlier description below that excludes all three question families is
superseded for this non-Richese Basic profile. The Richese Semuta envelope,
phase-wide bid questions, combined turn-action questions, Nexus Kull, Advanced,
unfunded non-CHOAM overbids and wider combinations remain outside this slice.
Automatic Kull activation and full rules/deployed acceptance remain open.
The real-start evidence is in [bid](../tests/kull-bid-runtime.test.ts),
[Revival](../tests/kull-truth-revival-runtime.test.ts) and
[alliance](../tests/kull-alliance-runtime.test.ts) tests. The alliance test
reaches an actual worm Nexus but does not claim a Kull interruption there.
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 Richese and Kull/Semuta — 27 October 2026
+10
View File
@@ -20,6 +20,16 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Basic Kull and Truthtrance bid, Revival and alliance — 27 October. Prototyped / Partial.**
The existing Basic printed-Kull opt-in without Richese admits supported
current-lot bid, Revival and ordinary Nexus alliance questions through their
native controls. A Kull transaction suspends controls while the original action
resolves; a Karama purchase cannot evade its actor's bid Yes. Richese Semuta,
phase-wide bid and combined turn-action questions remain excluded, as do
unfunded non-CHOAM overbids. Automatic Kull activation, wider interactions and
full rules/deployed acceptance remain open.
[Scope](CHOAM_KULL_DESIGN.md#basic-kull-and-native-truthtrance-questions--27-october-2026).
**Truthtrance ground-movement sectors — 27 October. Prototyped / Partial.**
A future physical-movement condition can name the whole destination territory
or one exact printed sector. Separate eligible moves may satisfy separate
+9
View File
@@ -1,5 +1,14 @@
# Dune implementation status
## 27 October 2026 — Basic Kull with native Truthtrance questions
The supported non-Richese Basic Kull opt-in admits current-lot bidding,
Revival and Nexus alliance questions using their existing native controls.
The pending Kull transaction suspends question controls, and a voluntary
Karama purchase cannot evade its owner's current-lot Yes. Richese Semuta,
phase-wide bids, whole-turn answers and broader interactions remain open.
[Contract](CHOAM_KULL_DESIGN.md#basic-kull-and-native-truthtrance-questions--27-october-2026).
## 27 October 2026 — Truthtrance ground-movement sector
Future physical-movement conditions may name an exact printed sector.
+7 -4
View File
@@ -1,4 +1,5 @@
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
import { bribePromiseModeSupported } from './bribe-promises';
import { NexusAllianceError, quoteNexusAlliance } from './nexus-alliance';
export type AllianceClaim = { turn: number; partner: string };
@@ -39,14 +40,16 @@ export function parseAllianceClaim(value: unknown): AllianceClaim {
return { turn: input.turn, partner: input.partner };
}
/** This is the original bare native context. An ordinary worm Nexus does not
* require selecting the optional Nexus Cards module or change a start gate. */
/** An ordinary worm Nexus does not require the optional Nexus Cards module.
* Printed Kull can coexist in its existing supported Basic bribe envelope;
* native offers and final membership retain their independent continuation. */
export function alliancePromiseModeSupported(game: NativePromiseScope & {
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
richeseBetrayalPreview?: unknown;
}): boolean {
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
!game.nexusKullPreview && !game.richeseBetrayalPreview;
return nativePromiseScopeSupported(game) && !game.semutaPreview &&
!game.nexusKullPreview && !game.richeseBetrayalPreview &&
(!game.kullPreview || bribePromiseModeSupported(game));
}
export function allianceClaimText(claim: AllianceClaim, partnerName: (id: string) => string = id => id): string {
+8 -3
View File
@@ -1,4 +1,5 @@
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
import { bribePromiseModeSupported } from './bribe-promises';
export type BidClaim = { turn: number; lot: number; minimum: number };
export type BidPromise = BidClaim & {
@@ -31,10 +32,14 @@ export function parseBidClaim(value: unknown): BidClaim {
* retain their own required future commitment work. Never changes start gates. */
export function bidPromiseModeSupported(game: NativePromiseScope & {
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
richeseBetrayalPreview?: unknown;
richeseBetrayalPreview?: unknown; guildBetrayalPreview?: unknown;
nexusIxianReplacementPreview?: unknown; nexusIxianBetrayalPreview?: unknown;
nexusHarkonnenBetrayalPreview?: unknown; moritaniAssassinatePreview?: unknown;
mobileStronghold?: unknown;
}): boolean {
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
!game.nexusKullPreview && !game.richeseBetrayalPreview;
return nativePromiseScopeSupported(game) && !game.semutaPreview &&
!game.nexusKullPreview && !game.richeseBetrayalPreview &&
(!game.kullPreview || bribePromiseModeSupported(game));
}
export function bidClaimText(claim: BidClaim): string {
+11 -5
View File
@@ -15417,7 +15417,7 @@ function allianceOpportunity(g: Game): AllianceOpportunity | null {
}
function allianceQuestionWindow(g: Game): boolean {
return !!allianceOpportunity(g) && !g.spiceWindow && !g.spiceResolution && !g.response &&
!g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama;
!g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama && !g.pendingKull;
}
/** The original native reciprocal quote owns identity and pairing legality. */
function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
@@ -16216,7 +16216,7 @@ function bidOpportunity(g: Game): BidOpportunity | null {
}
function bidQuestionWindow(g: Game): boolean {
return !!bidOpportunity(g) && !g.response && !g.decision && !g.phaseOpening && !g.richeseAuction &&
!g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard;
!g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingKull;
}
/** A future lot is a witness only after a real funded bid has committed the
* current lot to sale. An unbid lot ends ordinary Bidding instead of opening
@@ -16445,7 +16445,7 @@ function projectedRicheseBidGuide(g: Game, player: string): {action:Action} | nu
function phaseBidQuestionWindow(g: Game): boolean {
return g.status === 'playing' && g.phase >= 0 && g.phase <= 3 && phaseBidPromiseModeSupported(g) &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
!g.richeseAuction && !g.ixAuction && !g.currentAuctionSale && (g.phase < 3 || !!bidOpportunity(g));
!g.pendingKull && !g.richeseAuction && !g.ixAuction && !g.currentAuctionSale && (g.phase < 3 || !!bidOpportunity(g));
}
function phaseBidHonorsTurnActions(g: Game, p: Player, lot: BidOpportunity, action: Action): boolean {
if (!liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length) return true;
@@ -17681,6 +17681,12 @@ function normalKaramaAuction(
!payment || a.bidder === p.id,
'Only the original winning bidder may choose the auction payment.',
);
// A direct purchase closes the lot without placing the promised bid. Check
// before opening Kull: a later interruption must not hide the actor's own
// voluntary attempt to evade the original Truthtrance answer.
const opportunity = !payment && g.kullPreview ? bidOpportunity(g) : null;
requireRule(!opportunity || !bidRequired(g.bidPromises ?? [], p.id, opportunity),
'Place your promised Truthtrance bid before taking this lot with Karama.');
requireRule(
p.hand.length - Number(payment && spendingCard) < handLimit(p),
'Leave room for the committed Karama auction purchase.',
@@ -34362,7 +34368,7 @@ function revivalOpportunity(g: Game, player: string): boolean {
function revivalQuestionWindow(g: Game): boolean {
return g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && revivalPromiseModeSupported(g) &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival &&
!g.pendingKarama && !g.pendingTreacheryDiscard;
!g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingKull;
}
function stagePromisedForceReturn(g: Game, p: Player, revival: PendingRevival) {
const quote = currentRevivalResumeQuote({ ...g, pendingRevival: { ...revival, checks: [] } }, 'finish');
@@ -34713,7 +34719,7 @@ function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action:
function projectedTypedRevivalPromiseGuide(g: Game, player: string, kind: 'forces' | 'leader' | 'expression'): { phase: number; action: Action } | null {
const ownPayment = g.phase === 3 && !g.response && !g.phaseOpening && g.decision?.kind === 'auctionPayment' && g.decision.player === player;
if ((!revivalQuestionWindow(g) && !ownPayment) || g.truthtrance || !revivalOpportunity(g, player) ||
g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard ||
g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingKull ||
g.phase === 4 && nativeRevivalOrderBlock(g, player) ||
g.phase === 2 && nativeCharityOrderBlock(g, player)) return null;
// The separate bid guide owns a jointly binding Yes declaration. Passing
+2 -2
View File
@@ -4783,7 +4783,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.',
steps: [
'In the lobby, the host can opt into this Basic development preview with CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions, the CHOAM and Ix decks, each seated faction’s deck, 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.',
'In the supported Basic printed Kull preview without Richese, ordinary, grouped and mixed Truthtrance bribe questions remain available. The existing Semuta preview does not yet admit structured bribe commitments. A pending Kull response temporarily holds bribe controls; its real resolution resumes the existing commitment. Other structured question types remain incomplete in this preview.',
'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.',
'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.',
@@ -4797,7 +4797,7 @@ export const RULE_TOPICS: RuleTopic[] = [
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Opt-in Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, structured bribe promises persist through its response; deferred overbid recovery and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-native-ix-runtime.test.ts', 'tests/kull-native-e3-runtime.test.ts', 'tests/kull-ground-movement-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Opt-in Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, supported current-lot bid, Revival, alliance and bribe commitments can coexist with the preview; deferred overbid recovery, phase-wide and whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
+10 -5
View File
@@ -1,4 +1,5 @@
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
import { bribePromiseModeSupported } from './bribe-promises';
export type RevivalClaim = { turn: number; counter: 'total' | 'normal' | 'elite'; minimum: number };
export type RevivalPromise = RevivalClaim & {
@@ -32,14 +33,18 @@ export function parseRevivalClaim(value: unknown): RevivalClaim {
return { turn: input.turn, counter: input.counter, minimum: input.minimum };
}
/** The original bare native scope is unchanged. Timing, allowance, payment,
* typed Tanks and actual ordinary-return sources remain the engine's rules. */
/** The original bare native scope and the supported Basic printed-Kull roster.
* Timing, allowance, payment, typed Tanks and actual ordinary-return sources
* remain the engine's rules; Richese Semuta and Nexus Kull stay fenced. */
export function revivalPromiseModeSupported(game: NativePromiseScope & {
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
richeseBetrayalPreview?: unknown;
richeseBetrayalPreview?: unknown; guildBetrayalPreview?: unknown;
nexusIxianReplacementPreview?: unknown; nexusIxianBetrayalPreview?: unknown;
nexusHarkonnenBetrayalPreview?: unknown; moritaniAssassinatePreview?: unknown;
mobileStronghold?: unknown;
}): boolean {
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
!game.nexusKullPreview && !game.richeseBetrayalPreview;
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.nexusKullPreview &&
!game.richeseBetrayalPreview && (!game.kullPreview || bribePromiseModeSupported(game));
}
export function revivalClaimText(claim: RevivalClaim): string {
+5 -1
View File
@@ -42,7 +42,11 @@ export function parsePhaseBidClaim(value: unknown): PhaseBidClaim {
return { turn: input.turn, minimum: input.minimum };
}
export const phaseBidPromiseModeSupported = bidPromiseModeSupported;
/** A phase-wide Yes can depend on an unopened later lot; its Kull purchase
* interruption needs a separate continuation proof from a current-lot Yes. */
export function phaseBidPromiseModeSupported(game: Parameters<typeof bidPromiseModeSupported>[0]): boolean {
return !game.kullPreview && bidPromiseModeSupported(game);
}
export function phaseBidClaimText(claim: PhaseBidClaim): string {
return `place a bid of at least ${claim.minimum} spice from now until this turn's Bidding phase closes`;
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { TruthQuestion } from '../game/truthtrance';
import { createKullBribeFixture, act, advance, clean, inventory, player } from './fixture-kull-bribe';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
import { nextKullPurposeStep } from './fixture-kull-purpose';
// The unopened physical cards are ordered by the inherited fixture; only a later
// unopened worm and replacement are selected here. No ally or promise is staged.
function source() {
const f = createKullBribeFixture();
let game = advance(structuredClone(f.game), g => g.turn === 2 && beforeIxSpiceDraw(g));
orderIxSpiceCards(game, ['worm', 'land']);
game = advance(game, g => g.phase === 1 && g.nexus && clean(g) && !g.spiceWindow && !g.spiceResolution);
game = act(game, f.counterOwner, { type: 'alliance', target: f.actor });
return { f, game };
}
function close(game: Game): Game {
for (let i = 0; i < 300 && game.nexus; i++) {
const guide = game.players.map(p => ({ p, guide: viewGame(game, p.id).alliancePromiseGuide }))
.find(row => row.guide);
const step = guide ? { actor: guide.p.id, action: guide.guide!.action } : nextKullPurposeStep(game);
assert.ok(step, 'Native Nexus has a legal continuation');
game = act(game, step.actor, step.action);
}
assert.equal(game.nexus, false); return game;
}
for (const kind of ['alliance', 'allianceExpression', 'mixedAlliance'] as const) {
void test(`printed Kull Basic game resolves native ${kind} Truthtrance in the ordinary worm Nexus`, () => {
const { f, game: original } = source(); let game = original;
game = act(game, f.asker, { type: 'card', card: f.truth });
for (let i = 0; game.truthtrance?.stage === 'priority' && i < game.players.length; i++) {
const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat);
game = act(game, seat.id, { type: 'truthPass' });
}
const question: TruthQuestion = kind === 'alliance'
? { kind, target: f.actor, turn: game.turn, partner: f.counterOwner }
: kind === 'allianceExpression'
? { kind, target: f.actor, turn: game.turn, claim: { kind: 'partner', partner: f.counterOwner } }
: { kind, target: f.actor, turn: game.turn, mixed: { kind: 'and', terms: [
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action: { partner: f.counterOwner } },
] } };
game = act(game, f.asker, { type: 'truthAsk', question });
game = act(game, f.actor, { type: 'truthAnswer', answer: 'yes' });
const guide = viewGame(game, f.actor).alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.counterOwner);
const bot = viewGame(game, f.actor); bot.players.find(p => p.id === f.actor)!.bot = 'Easy';
const botAlliance = botActions(bot).find(action => action.alliancePromiseEvent === guide.action.alliancePromiseEvent); assert.ok(botAlliance);
assert.equal(player(act(structuredClone(game), f.actor, botAlliance), f.actor).ally, f.counterOwner);
game = act(game, f.actor, guide.action);
assert.equal(player(game, f.actor).ally, f.counterOwner);
game = close(game);
const view = viewGame(game, f.actor);
const rows = kind === 'alliance' ? view.alliancePromises : kind === 'allianceExpression'
? view.allianceExpressionPromises : view.mixedAlliancePromises;
assert.ok(rows.some(row => row.fulfilled));
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.ok(player(game, f.reactor).hand.some(card => card.id === f.kull));
assert.deepEqual(inventory(game), f.originalCards);
});
}
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { alliancePromiseModeSupported as supported } from '../game/alliance-promises';
const basic = { advanced: false, expansions: ['choam', 'ix'],
players: [{ faction: 'choam' }, { faction: 'emperor' }, { faction: 'guild' }], kullPreview: true };
void test('printed Kull admission reuses the supported Basic CHOAM envelope', () => {
assert.equal(supported(basic), true);
assert.equal(supported({ ...basic, players: [...basic.players, { faction: 'ixians' }] }), true);
assert.equal(supported({ ...basic, players: [...basic.players, { faction: 'tleilaxu' }] }), true);
assert.equal(supported({ ...basic, expansions: ['choam', 'ix', 'ecaz'],
players: [...basic.players, { faction: 'moritani' }] }), true);
assert.equal(supported({ ...basic, advanced: true }), false);
assert.equal(supported({ ...basic, expansions: ['choam'] }), false);
assert.equal(supported({ ...basic, players: [{ faction: 'emperor' }, { faction: 'guild' }] }), false);
assert.equal(supported({ ...basic, players: [...basic.players, { faction: 'guild' }] }), false);
assert.equal(supported({ ...basic, kullPreview: false }), true);
for (const flag of ['semutaPreview', 'nexusKullPreview', 'richeseBetrayalPreview',
'guildBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
'nexusHarkonnenBetrayalPreview', 'moritaniAssassinatePreview', 'nexusCards', 'homeworlds',
'leaderSkills', 'techTokens', 'strongholdCards', 'discoveryEnabled', 'ecazTreachery']) {
assert.equal(supported({ ...basic, [flag]: true }), false, flag);
}
});
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { bidPromiseModeSupported } from '../game/bid-promises';
import { phaseBidPromiseModeSupported } from '../game/turn-bid-promises';
import { act, advance, clean, createKullBribeFixture, inventory, player } from './fixture-kull-bribe';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
void test('Basic printed Kull admits native bid commitments only in its supported envelope', () => {
const scope = { advanced: false, expansions: ['choam', 'ix'],
players: [{ faction: 'choam' }, { faction: 'emperor' }], kullPreview: true };
assert.equal(bidPromiseModeSupported(scope), true);
assert.equal(phaseBidPromiseModeSupported(scope), false);
assert.equal(bidPromiseModeSupported({ ...scope, advanced: true }), false);
assert.equal(bidPromiseModeSupported({ ...scope, expansions: ['choam'] }), false);
assert.equal(bidPromiseModeSupported({ ...scope, semutaPreview: true }), false);
assert.equal(bidPromiseModeSupported({ ...scope, nexusKullPreview: true }), false);
assert.equal(bidPromiseModeSupported({ ...scope, leaderSkills: {} }), false);
assert.equal(bidPromiseModeSupported({ ...scope, players: [{ faction: 'emperor' }, { faction: 'guild' }] }), false);
});
void test('a genuine Basic Kull auction preserves the respondent’s real Yes bid and payment', () => {
const f = createKullBribeFixture();
let game = advance(structuredClone(f.charity), g => g.phase === 3 && clean(g) && g.auction?.active === f.actor);
assert.ok(game.auction);
const lot = game.auction.cards[game.auction.index].id;
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(passer); game = act(game, passer.id, { type: 'truthPass' });
}
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'bid', target: f.actor, turn: game.turn, lot: game.auction!.index + 1, minimum: 4,
} });
assert.ok(viewGame(game, f.actor).truthBidAnswers?.includes('yes'));
game = act(game, f.actor, { type: 'truthAnswer', answer: 'yes' });
rejected(game, f.actor, { type: 'passBid' });
rejected(game, f.actor, { type: 'bid', amount: 3 });
const guide = viewGame(game, f.actor).bidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 4);
const bot = viewGame(game, f.actor); bot.players.find(p => p.id === f.actor)!.bot = 'Easy';
const botBid = botActions(bot).find(action => action.bidPromiseEvent === guide.action.bidPromiseEvent); assert.ok(botBid);
assert.equal(act(structuredClone(game), f.actor, botBid).bidPromises?.[0].fulfilled, true);
game = act(game, f.actor, guide.action);
assert.ok(game.bidPromises?.some(row => row.player === f.actor && row.fulfilled));
game = advance(game, g => player(g, f.actor).hand.some(card => card.id === lot));
assert.equal(player(game, f.actor).spice, 6);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a Yes bid cannot be evaded by buying its own lot with Karama', () => {
const f = createKullBribeFixture();
let game = advance(structuredClone(f.charity), g => g.phase === 3 && clean(g) && g.auction?.active === f.actor);
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(passer); game = act(game, passer.id, { type: 'truthPass' });
}
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'bid', target: f.actor, turn: game.turn, lot: game.auction!.index + 1, minimum: 4,
} });
game = act(game, f.actor, { type: 'truthAnswer', answer: 'yes' });
rejected(game, f.actor, { type: 'card', mode: 'purchase', card: f.karama });
});
void test('a No bid caps the actual current-lot declaration in a Kull game', () => {
const f = createKullBribeFixture();
let game = advance(structuredClone(f.charity), g => g.phase === 3 && clean(g) && g.auction?.active === f.actor);
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(passer); game = act(game, passer.id, { type: 'truthPass' });
}
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'bid', target: f.actor, turn: game.turn, lot: game.auction!.index + 1, minimum: 4,
} });
game = act(game, f.actor, { type: 'truthAnswer', answer: 'no' });
rejected(game, f.actor, { type: 'bid', amount: 4 });
game = act(game, f.actor, { type: 'bid', amount: 3 });
assert.equal(game.auction?.bid, 3);
assert.equal(game.bidPromises?.[0].answer, false);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('an opposing Karama purchase and Kull response suspend and then reconcile a Yes bid', () => {
const f = createKullBribeFixture();
let game = advance(structuredClone(f.charity), g => g.phase === 3 && clean(g) && g.auction?.active === f.actor);
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(passer); game = act(game, passer.id, { type: 'truthPass' });
}
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'bid', target: f.actor, turn: game.turn, lot: game.auction!.index + 1, minimum: 4,
} });
game = act(game, f.actor, { type: 'truthAnswer', answer: 'yes' });
const before = structuredClone(game.bidPromises);
const pending = act(game, f.counterOwner, { type: 'card', mode: 'purchase', card: f.counter });
assert.equal(pending.pendingKull?.stage, 'offer');
assert.deepEqual(pending.bidPromises, before);
assert.equal(viewGame(pending, f.actor).bidPromiseGuide, null);
const reaction = viewGame(pending, f.reactor).kullReaction; assert.ok(reaction);
const declined = advance(act(pending, f.reactor,
{ type: 'kullDecision', event: reaction.event, decline: true }), clean);
assert.equal(declined.bidPromises?.[0].released, true);
assert.ok(player(declined, f.reactor).hand.some(card => card.id === f.kull));
assert.deepEqual(inventory(declined), f.originalCards);
const declared = act(pending, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
assert.deepEqual(declared.bidPromises, before);
game = advance(declared, clean);
assert.deepEqual(game.bidPromises, before);
const guide = viewGame(game, f.actor).bidPromiseGuide; assert.ok(guide);
game = act(game, f.actor, guide.action);
assert.equal(game.bidPromises?.[0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
+139
View File
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TERRITORIES } from '../game/board';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { revivalPromiseModeSupported } from '../game/revival-promises';
import { act, advance, clean, createKullBribeFixture, inventory, player } from './fixture-kull-bribe';
function source() {
const f = createKullBribeFixture();
let game = structuredClone(f.game);
// The original driver selects the scalar lottery and dials one for each
// player in the next Storm. Select a real empty next-Storm sand sector.
const sector = game.storm % 18 + 1;
const destination = TERRITORIES.find(place => place.type === 'sand' && place.sectors.includes(sector) &&
game.players.every(seat => !Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`))));
assert.ok(destination);
game = act(game, f.actor, { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 });
game = advance(game, clean);
game = act(game, f.actor, { type: 'endMovement' });
// Reorder only the still-unopened Spice deck so the next turn has a normal
// blow and cannot open an unrelated random Nexus decision.
const safe = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(safe >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(safe, 1)[0]);
game = advance(game, state => state.turn === 2 && state.phase === 3 && clean(state) && state.auction?.active === f.actor);
assert.equal(player(game, f.actor).tanks, 3);
assert.equal(player(game, f.actor).spice, 4);
assert.deepEqual(inventory(game), f.originalCards);
return { ...f, game };
}
function ask(f: ReturnType<typeof source>, game: Game) {
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id));
assert.ok(passer); game = act(game, passer.id, { type: 'truthPass' });
}
return act(game, f.asker, { type: 'truthAsk', question: {
kind: 'revival', target: f.actor, turn: game.turn, counter: 'total', minimum: 3,
} });
}
void test('Basic printed Kull permits native Revival questions while preserving module fences', () => {
const native = { advanced: false, expansions: ['choam', 'ix'],
players: [{ faction: 'choam' }, { faction: 'emperor' }], kullPreview: true };
assert.equal(revivalPromiseModeSupported(native), true);
for (const option of ['semutaPreview', 'nexusKullPreview', 'nexusCards', 'leaderSkills',
'richeseBetrayalPreview', 'guildBetrayalPreview'] as const)
assert.equal(revivalPromiseModeSupported({ ...native, [option]: true }), false, option);
assert.equal(revivalPromiseModeSupported({ ...native, advanced: true }), false);
assert.equal(revivalPromiseModeSupported({ ...native, players: [{ faction: 'emperor' }, { faction: 'guild' }] }), false);
});
void test('a genuine Kull game binds and completes an ordinary Revival Yes after real Storm losses', () => {
const f = source();
let game = advance(f.game, state => state.phase === 4 && clean(state));
game = ask(f, game);
assert.ok(viewGame(game, f.actor).truthRevivalAnswers?.includes('yes'));
game = act(game, f.actor, { type: 'truthAnswer', answer: 'yes' });
const guide = viewGame(game, f.actor).revivalPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'revive'); assert.equal(guide.action.amount, 3);
const bot = viewGame(game, f.actor); bot.players.find(p => p.id === f.actor)!.bot = 'Easy';
const botRevival = botActions(bot).find(action => action.revivalPromiseEvent === guide.action.revivalPromiseEvent); assert.ok(botRevival);
assert.equal(act(structuredClone(game), f.actor, botRevival).decision?.kind, 'choamFreeRevival');
game = act(game, f.actor, guide.action);
assert.equal(game.decision?.kind, 'choamFreeRevival');
assert.equal(game.revivalPromises?.[0].progress, 0);
game = advance(game, clean);
assert.equal(game.revivalPromises?.[0].progress, 3);
assert.equal(game.revivalPromises?.[0].fulfilled, true);
assert.equal(player(game, f.actor).tanks, 0);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a successful Kull auction block cannot be forecast as a Karama payment funding Revival', () => {
const f = source();
let game = act(f.game, f.actor, { type: 'card', mode: 'purchase', card: f.karama });
const reaction = viewGame(game, f.reactor).kullReaction; assert.ok(reaction);
game = act(game, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
game = advance(game, clean);
assert.ok(viewGame(game, f.actor).karamaBlocked);
game = act(game, f.actor, { type: 'bid', amount: 3, allyPayment: 0 });
game = ask(f, game);
assert.deepEqual(viewGame(game, f.actor).truthRevivalAnswers, ['no']);
const before = structuredClone(game);
assert.throws(() => act(game, f.actor, { type: 'truthAnswer', answer: 'yes' }));
assert.deepEqual(game, before);
game = act(game, f.actor, { type: 'truthAnswer', answer: 'no' });
assert.equal(game.revivalPromises?.[0].answer, false);
assert.deepEqual(inventory(game), f.originalCards);
});
function outstandingPayment() {
const f = source();
let game = act(f.game, f.actor, { type: 'bid', amount: 3, allyPayment: 0 });
game = ask(f, game);
assert.ok(viewGame(game, f.actor).truthRevivalAnswers?.includes('yes'));
game = act(game, f.actor, { type: 'truthAnswer', answer: 'yes' });
const promise = structuredClone(game.revivalPromises);
game = advance(game, state => !state.response && !state.phaseOpening &&
state.decision?.kind === 'auctionPayment' && state.decision.player === f.actor);
const payment = viewGame(game, f.actor).revivalPromiseGuide; assert.ok(payment);
assert.equal(payment.action.type, 'decision'); assert.equal(payment.action.karama, true);
game = act(game, f.actor, payment.action);
assert.equal(game.pendingKull?.stage, 'offer');
assert.deepEqual(game.revivalPromises, promise);
assert.equal(viewGame(game, f.actor).revivalPromiseGuide, null);
const reaction = viewGame(game, f.reactor).kullReaction; assert.ok(reaction);
game = act(game, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
assert.equal(game.pendingKull?.stage, 'counter');
assert.deepEqual(game.revivalPromises, promise);
assert.equal(viewGame(game, f.actor).revivalPromiseGuide, null);
return { f, game, promise };
}
void test('an opposing successful Kull may release a Revival Yes only after preventing its actual payment', () => {
const { f, game: offered, promise } = outstandingPayment();
const game = advance(offered, clean);
assert.equal(promise?.[0].released, undefined);
assert.equal(game.revivalPromises?.[0].progress, 0);
assert.equal(game.revivalPromises?.[0].released, true);
assert.equal(player(game, f.actor).spice, 1);
assert.ok(player(game, f.actor).hand.some(card => card.id === f.karama));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a distinct Kull counter preserves the original payment and outstanding Revival Yes', () => {
const { f, game: offered, promise } = outstandingPayment();
assert.ok(viewGame(offered, f.counterOwner).responseControls?.cancelCards.includes(f.counter));
let game = act(offered, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
game = advance(game, state => state.phase === 4 && clean(state));
assert.deepEqual(game.revivalPromises, promise);
assert.equal(player(game, f.actor).spice, 4);
const guide = viewGame(game, f.actor).revivalPromiseGuide; assert.ok(guide);
game = advance(act(game, f.actor, guide.action), clean);
assert.equal(game.revivalPromises?.[0].progress, 3);
assert.equal(game.revivalPromises?.[0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
+3 -3
View File
@@ -30,14 +30,14 @@ void test('public host lobby shows the explicitly selected Kull preview and its
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,/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 bribe commitments are also unavailable/);
assert.match(html,/When Richese is seated, structured bid, alliance, Revival and bribe commitments are 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.doesNotMatch(table(unselected),/does not support structured Truthtrance questions about bids/);
assert.doesNotMatch(table(unselected),/does not support phase-wide bid or combined turn-action Truthtrance questions/);
const nonhost = table(viewGame(f.lobby,f.actor));
assert.match(nonhost,/disabled=""[^>]*type="checkbox"|type="checkbox"[^>]*disabled=""/);
});