Support Truthtrance bribes in Basic Kull preview
This commit is contained in:
1 parent
92c68ec4f6
commit
21f95bdd5b
15 files changed
+532
-15
No files matched your search
@@ -2211,7 +2211,7 @@ export function GameTable({
|
||||
</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,
|
||||
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
|
||||
CHOAM rules. Every player must ready again after changing it.
|
||||
</p>}
|
||||
|
||||
@@ -2,6 +2,44 @@
|
||||
|
||||
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 Kull and Truthtrance bribes — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The printed Kull preview now also supports existing
|
||||
structured ordinary bribe-payment questions in its Basic CHOAM/classic roster,
|
||||
including grouped and mixed questions that use the same bribe rules. Yes and
|
||||
No retain their native cumulative payment meaning, private respondent guidance
|
||||
and the original deferred receipt. Other independent Truthtrance commitments
|
||||
remain under their existing preview boundaries.
|
||||
|
||||
A pending Kull attempt temporarily suppresses ordinary bribe controls. For a
|
||||
feasibility quote, the engine considers the suspended parent on a detached copy;
|
||||
this allows a genuinely available later Charity claim to fund the original bribe
|
||||
without treating the temporary Kull response as impossibility. At the unsettled
|
||||
phase-two CHOAM opening, either outcome preserves another faction’s Charity
|
||||
entitlement under the existing bank-shortfall policy; the quote does not choose
|
||||
which response will actually settle. Decline or a
|
||||
distinct eligible counter resumes the original attempt; successful Kull prevents
|
||||
it and retains its physical card. Each outcome retains the bribe obligations
|
||||
and their private ledger. No actual
|
||||
Charity or bribe is paid by the quote, and the real Kull frame is not retired or
|
||||
replayed by it.
|
||||
|
||||
The [publisher's Basic Kull rule](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7)
|
||||
and the general [Truthtrance current-turn commitment authority](TRUTHTRANCE_BRIBE_PROMISES.md#authority-and-provisional-composition)
|
||||
do not prescribe this online continuation, so the composition remains
|
||||
provisional. Existing distinct-counter timing and the user-deferred non-CHOAM
|
||||
unfunded-overbid fence remain. Nexus Kull, Advanced, revival, bidding, alliance,
|
||||
whole-turn compositions and broader combinations are separate; this does not
|
||||
certify complete modes, recovery, browser or deployed behavior.
|
||||
|
||||
The focused public fixture selects the original unopened Kull, two Karama,
|
||||
Truthtrance and Spice positions before native setup. Actual Charity-opening
|
||||
responses exercise a previously promised Charity-funded payment through decline,
|
||||
success and a distinct counter; two-recipient AND/OR and mixed questions use
|
||||
real later payments. A conserved Kull/deck swap is labeled as a controlled
|
||||
supplement. Thirteen related files pass 140/140; type/lint and build pass.
|
||||
Server-rendered controls do not certify browser or deployed play.
|
||||
|
||||
## Basic public start — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** An explicit lobby opt-in enables the existing printed
|
||||
@@ -19,8 +57,9 @@ 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
|
||||
commitments, including ordinary bid, alliance, revival and combined
|
||||
turn-action questions, remain unavailable in this preview. Structured bribe
|
||||
questions are supported as described above. 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
|
||||
|
||||
@@ -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 bribes — 27 October. Prototyped / Partial.**
|
||||
The printed Kull preview admits structured ordinary, grouped and mixed bribe
|
||||
questions. A pending Kull response suppresses bribe controls and quotes any
|
||||
later Charity-funded payment on a detached parent; decline, successful Kull or
|
||||
a distinct counter preserves the original Karama outcome and bribe continuation.
|
||||
The user-deferred non-CHOAM unfunded-overbid fence remains. Nexus, Advanced,
|
||||
other structured promise families and broader acceptance remain separate.
|
||||
Thirteen related files pass 140/140; type/lint and build pass.
|
||||
[Scope and source](CHOAM_KULL_DESIGN.md#basic-kull-and-truthtrance-bribes--27-october-2026).
|
||||
|
||||
**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
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Basic Kull and Truthtrance bribes
|
||||
|
||||
Admit ordinary, grouped and mixed structured bribe-payment questions in the
|
||||
Basic printed Kull preview. Pending Kull suppresses bribe controls while a
|
||||
detached parent quote retains a genuinely available future Charity-funded
|
||||
payment. The real Kull frame resolves once, and the private bribe ledger
|
||||
continues after decline, success or a distinct counter without replaying either action. Existing
|
||||
counter timing and the user-deferred non-CHOAM unfunded-overbid fence remain.
|
||||
This is a provisional composition; Nexus Kull, Advanced, other promise families,
|
||||
full modes, browser and deployed acceptance remain open. Thirteen related files
|
||||
pass 140/140; type/lint and build pass. Private proof is
|
||||
`20261027-kull-bribe-proof/`.
|
||||
[Contract](CHOAM_KULL_DESIGN.md#basic-kull-and-truthtrance-bribes--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Printed Kull in supported Basic starts
|
||||
|
||||
Offer an explicit lobby opt-in for the existing printed Kull response in a
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic Kull and Truthtrance bribes — 27 October 2026
|
||||
|
||||
Provisionally combine the printed Basic Kull preview with existing ordinary,
|
||||
grouped and mixed structured bribe-payment questions. Suspend bribe controls
|
||||
during the original Kull response and use a detached parent only to quote any
|
||||
later Charity-funded payment. Decline or a distinct counter resumes the
|
||||
original Karama attempt; successful Kull prevents it. All outcomes preserve the
|
||||
private bribe obligations. The publisher
|
||||
Kull rule and general Truthtrance commitment rule do not prescribe this online
|
||||
continuation. The user-deferred non-CHOAM unfunded-overbid fence is unchanged;
|
||||
Nexus, Advanced and other promise families remain separate.
|
||||
[Source and boundaries](CHOAM_KULL_DESIGN.md#basic-kull-and-truthtrance-bribes--27-october-2026).
|
||||
|
||||
## Printed Kull in supported Basic starts — 27 October 2026
|
||||
|
||||
Offer an explicit lobby opt-in for the existing printed Kull response in fresh
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Truthtrance ordinary bribe-payment promises
|
||||
|
||||
**27 October Basic Kull follow-up:** the printed Kull preview with native
|
||||
CHOAM, classic factions and the CHOAM/Ix decks now admits ordinary, grouped and
|
||||
mixed structured bribe questions. Pending Kull responses suppress bribe controls
|
||||
while a detached quote preserves a genuinely available future Charity-funded
|
||||
payment. The original Kull attempt and bribe ledger continue after decline,
|
||||
success or a distinct counter; the quote does not execute either effect. This
|
||||
provisional extension retains the user-deferred non-CHOAM unfunded-overbid fence
|
||||
and does not extend the other preview profiles or promise families.
|
||||
[Current contract](CHOAM_KULL_DESIGN.md#basic-kull-and-truthtrance-bribes--27-october-2026).
|
||||
|
||||
**27 October CHOAM follow-up:** the owned automatic opening receipt can fund
|
||||
later installments once, independently of one remaining ordinary Charity claim.
|
||||
Current payments use current cash. Native counters and opposing claims retain
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface BribeOptions {
|
||||
export function bribeTimingBlock(g: Game): string | null {
|
||||
if (g.status !== 'playing') return 'Bribes require a game in progress.';
|
||||
if (g.inflation?.side === 'double') return 'Bribes are prohibited while Inflation shows Double.';
|
||||
if (g.pendingTreacheryDiscard || g.pendingNullentropy || g.bureaucratPayments?.pending ||
|
||||
if (g.pendingKull || g.pendingTreacheryDiscard || g.pendingNullentropy || g.bureaucratPayments?.pending ||
|
||||
['name', 'reveal'].includes(g.battle?.mentatQuestion?.stage ?? '') ||
|
||||
g.nexusCards?.phase?.stage === 'drawing' || g.truthtrance ||
|
||||
g.nexusTraitorExchanges?.some(exchange => exchange.stage === 'return') ||
|
||||
|
||||
+21
-5
@@ -1,4 +1,5 @@
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
import { FACTIONS } from './catalog';
|
||||
|
||||
export type BribeClaim = { turn: number; recipient: string; minimum: number };
|
||||
export type BribePromise = BribeClaim & {
|
||||
@@ -35,14 +36,29 @@ export function parseBribeClaim(value: unknown): BribeClaim {
|
||||
return { turn: input.turn, recipient: input.recipient, minimum: input.minimum };
|
||||
}
|
||||
|
||||
/** Permission for the existing bare native context, not a game-start gate.
|
||||
* The engine retains nonally targets, timing and ordinary payment authority. */
|
||||
/** The ordinary, grouped and mixed bribe families share this admission. The
|
||||
* printed Kull extension is deliberately limited to the existing fresh Basic
|
||||
* CHOAM/classic roster; other previews keep their own composition boundaries.
|
||||
* This quote grants no start permission or future unowned funds. */
|
||||
export function bribePromiseModeSupported(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;
|
||||
if (!nativePromiseScopeSupported(game) || game.semutaPreview || game.nexusKullPreview ||
|
||||
game.richeseBetrayalPreview) return false;
|
||||
if (!game.kullPreview) return true;
|
||||
return game.kullPreview === true && game.advanced === false && game.expansions.length === 2 &&
|
||||
game.expansions.includes('choam') && game.expansions.includes('ix') &&
|
||||
game.players.some(player => player.faction === 'choam') &&
|
||||
new Set(game.players.map(player => player.faction)).size === game.players.length &&
|
||||
game.players.every(player => player.faction === 'choam' ||
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')) &&
|
||||
!game.guildBetrayalPreview && !game.nexusIxianReplacementPreview &&
|
||||
!game.nexusIxianBetrayalPreview && !game.nexusHarkonnenBetrayalPreview &&
|
||||
!game.moritaniAssassinatePreview && !game.mobileStronghold;
|
||||
}
|
||||
|
||||
export function bribeClaimText(claim: BribeClaim, recipientName: (id: string) => string = id => id): string {
|
||||
|
||||
+18
-3
@@ -1542,6 +1542,8 @@ export type Game = {
|
||||
form: 'printed' | 'substitution';
|
||||
/** New Advanced attempts restrict this effect; absent preserves original phase scope. */
|
||||
purpose?: KullPurpose;
|
||||
/** New Basic Kull attempts may bind an existing structured bribe ledger. */
|
||||
bribeReceipt?: true;
|
||||
stage: 'offer' | 'counter';
|
||||
selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string };
|
||||
intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent };
|
||||
@@ -15658,10 +15660,11 @@ function forecastOrdinaryCharity(source: Game, owner: string): Game | null {
|
||||
const player = getPlayer(g, owner);
|
||||
if (charityQuote(g, player).total <= 0) return null;
|
||||
try {
|
||||
if (source.phase < 2 && byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn) {
|
||||
if (source.phase <= 2 && byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn) {
|
||||
if (player.faction === 'choam') return null;
|
||||
// Another faction's ordinary entitlement survives either CHOAM opening
|
||||
// outcome. Quote the beneficiary only, without choosing that response.
|
||||
// outcome, including the phase-two opening before its response is
|
||||
// settled. Quote the beneficiary only, without choosing that response.
|
||||
player.spice += charityQuote(g, player).total; player.charityTurn = g.turn;
|
||||
return g;
|
||||
}
|
||||
@@ -15849,7 +15852,11 @@ function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Ac
|
||||
const pending = g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
|
||||
g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal;
|
||||
if (pending && voluntary) {
|
||||
const future = structuredClone(g); future.truthtrance = null;
|
||||
// Quote after the original Kull response on a detached source. Leaving
|
||||
// its frame installed would dispatch the simulated Charity as a Kull
|
||||
// decision instead of evaluating the payer's real future entitlement.
|
||||
const future = structuredClone(g.pendingKull ? kullSource(g, g.pendingKull) : g);
|
||||
future.truthtrance = null;
|
||||
if (future.phaseOpening) projectTurnActionOpeningEnd(future);
|
||||
future.response = null; future.decision = null; future.pendingKarama = null; future.pendingTreacheryDiscard = null;
|
||||
let affordable = false;
|
||||
@@ -18076,6 +18083,9 @@ function kullSignature(g: Game, frame: KullFrame): string {
|
||||
karamaShipping: source.karamaShipping, spiceWindow: source.spiceWindow,
|
||||
spiceResolution: source.spiceResolution, spiceSequence: source.spiceSequence,
|
||||
stormResolution: source.stormResolution, worthless: frame.worthless,
|
||||
...(frame.bribeReceipt ? {bribeReceipt:frame.bribeReceipt,bribePromises:source.bribePromises,
|
||||
bribeExpressionPromises:source.bribeExpressionPromises,bribePromiseSequence:source.bribePromiseSequence,
|
||||
mixedBribeQuestions:source.mixedBribeQuestions} : {}),
|
||||
});
|
||||
}
|
||||
/** Only the verified pre-source printed profile may bind its old held cost. */
|
||||
@@ -18120,6 +18130,9 @@ function kullIntegrity(g: Game): void {
|
||||
return;
|
||||
}
|
||||
requireRule(g.kullPreview === true, 'This table has not opted into the Kull preview.');
|
||||
requireRule(frame.bribeReceipt === undefined || frame.bribeReceipt === true && !g.advanced &&
|
||||
bribePromiseModeSupported(g) && !!(g.bribePromises?.length || g.bribeExpressionPromises?.length || g.mixedBribeQuestions?.length),
|
||||
'The Kull attempt has lost its original Basic bribe-answer ledger.');
|
||||
validateKullAttemptStamp(g, frame, physicalTreacheryCards(g));
|
||||
requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 &&
|
||||
frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]),
|
||||
@@ -18200,6 +18213,8 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int
|
||||
turn: g.turn, phase: g.phase, form: card.kind === 'worthless' ? 'substitution' : 'printed',
|
||||
stage: 'offer', intent: structuredClone(intent), resume: structuredClone(resume),
|
||||
...(g.advanced ? { purpose: kullIntentPurpose(g, intent) } : {}),
|
||||
...(!g.advanced && (g.bribePromises?.length || g.bribeExpressionPromises?.length || g.mixedBribeQuestions?.length)
|
||||
? { bribeReceipt: true as const } : {}),
|
||||
worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '',
|
||||
};
|
||||
// Payment dispatch removed this control before entering its native branch.
|
||||
|
||||
+3
-2
@@ -4779,6 +4779,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 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.',
|
||||
'In the supported Basic printed Kull preview, ordinary, grouped and mixed Truthtrance bribe questions remain available. A pending Kull response temporarily holds bribe controls; its real resolution resumes the existing commitment. Other structured question types remain incomplete in this preview.',
|
||||
'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.',
|
||||
@@ -4792,11 +4793,11 @@ 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. Deferred overbid recovery and combined-module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/engine.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 also retains structured bribe promises 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'] },
|
||||
{ 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'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: '18 October Advanced scope:12-file111/111, types/lint/build and two genuine-action/rendered-table programmes pass. Selected unopened deal/auction/Spice positions yield real Baliset purchase, paid shipments, repeated-effect rejection and a different-effect cancellation; Basic retains broad scope. Four policies legally pass. Controlled intent tests cover shared auction/special variants and unstamped frames. Earlier SQLite/phone evidence concerns the old scope, not current recovery/browser certification. Original Nexus/alias programmes, wider combinations, final assurance and deployed acceptance remain open.', evidence: ['tests/kull-purpose.test.ts', 'tests/kull-purpose-intents.test.ts', 'tests/kull-purpose-engine.test.ts', 'tests/fixture-kull-purpose.ts', 'tests/choam-kull-engine.test.ts', 'tests/choam-kull-recovery.test.ts', 'tests/choam-kull-controls.test.tsx', 'tests/bot-choam-kull.test.ts', 'tests/choam-kull.test.ts', 'tests/prototype-room.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: '18 October Advanced scope:12-file111/111, types/lint/build and two genuine-action/rendered-table programmes pass. Selected unopened deal/auction/Spice positions yield real Baliset purchase, paid shipments, repeated-effect rejection and a different-effect cancellation; Basic retains broad scope. Four policies legally pass. Controlled intent tests cover shared auction/special variants and unstamped frames. Earlier SQLite/phone evidence concerns the old scope, not current recovery/browser certification. Original Nexus/alias programmes, wider combinations, final assurance and deployed acceptance remain open.', evidence: ['tests/kull-purpose.test.ts', 'tests/kull-purpose-intents.test.ts', 'tests/kull-purpose-engine.test.ts', 'tests/fixture-kull-purpose.ts', 'tests/choam-kull-engine.test.ts', 'tests/choam-kull-recovery.test.ts', 'tests/choam-kull-controls.test.tsx', 'tests/bot-choam-kull.test.ts', 'tests/choam-kull.test.ts', 'tests/kull-bribe-runtime.test.ts', 'tests/kull-bribe-controls.test.tsx', 'tests/prototype-room.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import { actKullPurpose, advanceKullPurpose, kullPurposeClean, kullPurposePlayer, type KullPurposeStep } from './fixture-kull-purpose';
|
||||
import { publicKullInventory } from './fixture-public-choam-kull';
|
||||
|
||||
export const act = actKullPurpose;
|
||||
export const advance = advanceKullPurpose;
|
||||
export const clean = kullPurposeClean;
|
||||
export const player = kullPurposePlayer;
|
||||
export const inventory = publicKullInventory;
|
||||
export type KullBribeKind = 'bribe' | 'bribeExpression' | 'mixedBribe';
|
||||
|
||||
/** Public Basic opt-in, original unopened Treachery cards and a safe unopened
|
||||
* Spice position. All subsequent setup and phase movement uses native actions.
|
||||
* Cards are selected before the initial deal, not granted from a playing hand. */
|
||||
export function createKullBribeFixture() {
|
||||
let game = createGame('KULLBRIBE',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
|
||||
for (const id of ['emperor','guild','harkonnen'] as const) joinGame(game,newPlayer(id,id,id));
|
||||
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'});
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.kullPreview,true); assert.equal(game.status,'setup');
|
||||
assert.ok(game.players.every(seat => seat.hand.length === 0));
|
||||
const originalCards = game.deck.map(card=>card.id).sort(), selected:Game['deck']=[];
|
||||
for (const kind of ['kull','karama','truth','karama'] as const) {
|
||||
const i = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' :
|
||||
kind === 'truth' ? card.effect === 'truthtrance' : card.effect === 'karama');
|
||||
assert.ok(i >= 0); selected.push(game.deck.splice(i,1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
const beforeDeal = structuredClone(game);
|
||||
reach(g=>g.status === 'playing');
|
||||
const afterSetup = structuredClone(game), [kull,karama,truth,counter]=selected.map(card=>card.id);
|
||||
for (const [seat,card] of [['choam',kull],['emperor',karama],['guild',truth],['harkonnen',counter]] as const)
|
||||
assert.ok(player(game,seat).hand.some(held=>held.id === card),`${seat} received the selected original card`);
|
||||
assert.deepEqual(inventory(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 <= 1 && clean(g));
|
||||
const early = structuredClone(game);
|
||||
reach(g=>g.phase === 2 && clean(g) && g.choamCharity?.turn === g.turn);
|
||||
const charity = structuredClone(game);
|
||||
assert.equal(player(game,'emperor').spice,10);
|
||||
reach(g=>g.phase === 5 && g.active === 'emperor' && clean(g));
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
return {game, early, charity, lobby, setup, beforeDeal, afterSetup, originalCards, actions,
|
||||
kull,karama,truth,counter,actor:'emperor',asker:'guild',reactor:'choam',counterOwner:'harkonnen',recipient:'guild',
|
||||
staging:'Selected original unopened Kull, two Karama and Truth cards plus one safe unopened Spice position; all subsequent setup and progression use real actions.'};
|
||||
}
|
||||
export type KullBribeFixture = ReturnType<typeof createKullBribeFixture>;
|
||||
export function question(f:KullBribeFixture,kind:KullBribeKind,minimum:number,game:Game):TruthQuestion {
|
||||
if (kind === 'bribe') return {kind,target:f.actor,turn:game.turn,recipient:f.recipient,minimum};
|
||||
const leaf = {kind:'payment' as const,recipient:f.recipient,minimum};
|
||||
if (kind === 'bribeExpression') return {kind,target:f.actor,turn:game.turn,claim:leaf};
|
||||
return {kind,target:f.actor,turn:game.turn,mixed:{kind:'and',terms:[
|
||||
{kind:'fact',fact:{kind:'spice',compare:'eq',value:player(game,f.actor).spice}},
|
||||
{kind:'action',action:{recipient:f.recipient,minimum}},
|
||||
]}};
|
||||
}
|
||||
export function ask(f:KullBribeFixture,game:Game,kind:KullBribeKind='bribe',minimum=3):Game {
|
||||
game = act(game,f.asker,{type:'card',card:f.truth});
|
||||
for (let count=0;game.truthtrance?.stage === 'priority' && count<game.players.length;count++) {
|
||||
const seat = game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
|
||||
game=act(game,seat.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
return act(game,f.asker,{type:'truthAsk',question:question(f,kind,minimum,game)});
|
||||
}
|
||||
export function answer(f:KullBribeFixture,game:Game,value:'yes'|'no'):Game {
|
||||
return act(game,f.actor,{type:'truthAnswer',answer:value});
|
||||
}
|
||||
export function bind(f:KullBribeFixture,game:Game,value:'yes'|'no'='yes',kind:KullBribeKind='bribe',minimum=3) {
|
||||
const asked = ask(f,game,kind,minimum), bound = answer(f,asked,value);
|
||||
return {asked,game:bound};
|
||||
}
|
||||
export const attempt = (f:KullBribeFixture):Action => ({type:'card',mode:'shipment',card:f.karama,target:f.actor});
|
||||
export function reject(game:Game,actor:string,action:Action) {
|
||||
const before=structuredClone(game); assert.throws(()=>act(game,actor,action)); assert.deepEqual(game,before);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 Action, type GameView } from '../game/engine';
|
||||
import { BribePromises, BribePromiseGuideButton } from '../components/bribe-promises';
|
||||
import { act, createKullBribeFixture as fixture, bind, attempt } from './fixture-kull-bribe';
|
||||
|
||||
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();
|
||||
const table=(game:GameView)=>renderToStaticMarkup(createElement(GameTable,{game,busy:false,send:async()=>{},onExit(){}}));
|
||||
const panel=(game:GameView)=>renderToStaticMarkup(createElement(BribePromises,{game,busy:false,send(){}}));
|
||||
|
||||
void test('public Kull table renders a private bribe commitment and its real native callback',()=>{
|
||||
const f=fixture(), bound=bind(f,f.game,'yes').game, view=viewGame(bound,f.actor), sent:Action[]=[];
|
||||
assert.ok(view.bribePromiseGuide); assert.match(panel(view),/Your bribe promises/);
|
||||
assert.match(table(view),/data-testid="bribe-promise-guide"/);
|
||||
assert.doesNotMatch(panel(viewGame(bound,f.asker)),/Your bribe promises/);
|
||||
const button=BribePromiseGuideButton({game:view,busy:false,send(action){sent.push(action);}}); assert.ok(button);
|
||||
assert.equal(button.props.disabled,false); button.props.onClick();
|
||||
assert.deepEqual(sent,[view.bribePromiseGuide.action]);
|
||||
const paid=act(bound,f.actor,sent[0]); assert.ok(paid.bribePromises?.at(-1)?.fulfilled);
|
||||
const disabled=BribePromiseGuideButton({game:view,busy:true,send(){assert.fail('Busy guide cannot send');}}); assert.ok(disabled);
|
||||
assert.equal(disabled.props.disabled,true); disabled.props.onClick();
|
||||
});
|
||||
|
||||
void test('a pending neutral Kull reaction hides the actual bribe action until it resumes',()=>{
|
||||
const f=fixture(), bound=bind(f,f.game,'yes').game;
|
||||
let game=act(bound,f.actor,attempt(f));
|
||||
const pending=viewGame(game,f.actor); assert.ok(pending.kullReaction);
|
||||
assert.equal(pending.bribePromiseGuide,null);
|
||||
assert.doesNotMatch(panel(pending),/data-testid="bribe-promise-guide"/);
|
||||
assert.equal(BribePromiseGuideButton({game:pending,busy:false,send(){assert.fail('Kull owns the pending response');}}),null);
|
||||
const response=viewGame(game,f.reactor).kullReaction; assert.ok(response);
|
||||
game=act(game,f.reactor,{type:'kullDecision',event:response.event,decline:true});
|
||||
const resumed=viewGame(game,f.actor); assert.ok(resumed.bribePromiseGuide);
|
||||
const sent:Action[]=[]; const button=BribePromiseGuideButton({game:resumed,busy:false,send(action){sent.push(action);}}); assert.ok(button);
|
||||
button.props.onClick(); assert.deepEqual(sent,[resumed.bribePromiseGuide.action]);
|
||||
assert.ok(act(game,f.actor,sent[0]).bribePromises?.at(-1)?.fulfilled);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { act, advance, clean, createKullBribeFixture as fixture, player, inventory,
|
||||
ask, bind, attempt, reject, type KullBribeKind } from './fixture-kull-bribe';
|
||||
|
||||
function held(game:Game,owner:string,card:string) { return player(game,owner).hand.some(row=>row.id === card); }
|
||||
function offer(f:ReturnType<typeof fixture>,game:Game) {
|
||||
game=act(game,f.actor,attempt(f)); assert.equal(game.pendingKull?.stage,'offer');
|
||||
const reaction=viewGame(game,f.reactor).kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(row=>row.card.id === f.kull && !row.blocked));
|
||||
return {game,reaction};
|
||||
}
|
||||
function declare(f:ReturnType<typeof fixture>,game:Game) {
|
||||
const reaction=viewGame(game,f.reactor).kullReaction; assert.ok(reaction);
|
||||
return act(game,f.reactor,{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
|
||||
}
|
||||
|
||||
void test('public Basic Kull game admits an existing bribe Yes that requires native Charity funding',()=>{
|
||||
const f=fixture(); let game=act(f.charity,f.actor,{type:'bribe',target:f.recipient,amount:10});
|
||||
assert.equal(player(game,f.actor).spice,0); assert.equal(player(game,f.recipient).bribes,10);
|
||||
const asked=ask(f,game,'bribe',1);
|
||||
assert.ok(viewGame(asked,f.actor).truthBribeAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked,f.asker).truthBribeAnswers,null);
|
||||
game=act(asked,f.actor,{type:'truthAnswer',answer:'yes'});
|
||||
assert.equal(game.discard.filter(card=>card.id===f.truth).length,1);
|
||||
const guide=viewGame(game,f.actor).bribePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'charity');
|
||||
game=act(game,f.actor,guide.action);
|
||||
assert.equal(player(game,f.actor).charityTurn,game.turn); assert.equal(player(game,f.actor).spice,2);
|
||||
game=act(game,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
assert.equal(player(game,f.recipient).bribes,11); assert.ok(game.bribePromises?.at(-1)?.fulfilled);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
|
||||
void test('a Charity-funded Yes survives the original phase-two Kull interruption',()=>{
|
||||
const f=fixture(); let source=act(f.early,f.actor,{type:'bribe',target:f.recipient,amount:10});
|
||||
assert.equal(player(source,f.actor).spice,0);
|
||||
source=bind(f,source,'yes','bribe',1).game;
|
||||
assert.equal(source.bribePromises?.at(-1)?.fulfilled,undefined);
|
||||
const income=advance(source,g=>g.phase===2 && g.response?.kind==='choamCharity');
|
||||
assert.equal(income.choamCharity?.turn === income.turn,false);
|
||||
assert.equal(player(income,f.actor).charityTurn,undefined);
|
||||
const original=structuredClone(income.bribePromises);
|
||||
const pending=act(income,f.actor,{type:'card',mode:'cancel',card:f.karama});
|
||||
assert.equal(pending.pendingKull?.stage,'offer');
|
||||
assert.equal(pending.pendingKull?.resume.response?.kind,'choamCharity');
|
||||
assert.deepEqual(pending.bribePromises,original);
|
||||
assert.equal(viewGame(pending,f.actor).bribePromiseGuide,null);
|
||||
const reaction=viewGame(pending,f.reactor).kullReaction; assert.ok(reaction);
|
||||
const declined=act(pending,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
|
||||
const canceledIncome=advance(declined,clean);
|
||||
assert.equal(canceledIncome.choamCharity?.canceled,true);
|
||||
assert.deepEqual(canceledIncome.bribePromises,original);
|
||||
const claim=viewGame(canceledIncome,f.actor).bribePromiseGuide; assert.ok(claim);
|
||||
assert.equal(claim.action.type,'charity');
|
||||
let game=act(canceledIncome,f.actor,claim.action);
|
||||
game=act(game,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
assert.ok(game.bribePromises?.at(-1)?.fulfilled); assert.equal(player(game,f.recipient).bribes,11);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
const declared=declare(f,pending), success=advance(declared,clean);
|
||||
assert.equal(success.choamCharity?.canceled,false);
|
||||
assert.deepEqual(success.bribePromises,original);
|
||||
const counter=viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? [];
|
||||
assert.ok(counter.includes(f.counter));
|
||||
const intercepted=act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
|
||||
const resumed=advance(intercepted,clean);
|
||||
assert.equal(resumed.choamCharity?.canceled,true);
|
||||
assert.deepEqual(resumed.bribePromises,original);
|
||||
assert.ok(viewGame(resumed,f.actor).bribePromiseGuide);
|
||||
assert.deepEqual(inventory(success),f.originalCards); assert.deepEqual(inventory(resumed),f.originalCards);
|
||||
});
|
||||
|
||||
void test('simple No caps real cumulative payments throughout the neutral Kull offer',()=>{
|
||||
const f=fixture(); let game=bind(f,f.game,'no','bribe',2).game;
|
||||
game=act(game,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
reject(game,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
const expected=structuredClone(game.bribePromises), {game:pending,reaction}=offer(f,game);
|
||||
assert.deepEqual(pending.bribePromises,expected);
|
||||
assert.equal(viewGame(pending,f.actor).bribePromiseGuide,null);
|
||||
reject(pending,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
game=act(pending,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
|
||||
assert.deepEqual(game.bribePromises,expected);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
reject(game,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
assert.equal(player(game,f.recipient).bribes,1);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
|
||||
for (const kind of ['bribe','bribeExpression','mixedBribe'] as const satisfies readonly KullBribeKind[]) {
|
||||
void test(`public Basic ${kind} Yes survives Kull success and a distinct Karama counter`,()=>{
|
||||
const f=fixture(), bound=bind(f,f.game,'yes',kind,3).game;
|
||||
const prior=structuredClone({simple:bound.bribePromises,grouped:bound.bribeExpressionPromises,mixed:bound.mixedBribeQuestions});
|
||||
const {game:pending}=offer(f,bound);
|
||||
assert.equal(viewGame(pending,f.actor).bribePromiseGuide,null);
|
||||
assert.ok(held(pending,f.actor,f.karama)); assert.deepEqual(inventory(pending),f.originalCards);
|
||||
const declared=declare(f,pending);
|
||||
assert.equal(declared.pendingKull?.stage,'counter');
|
||||
assert.equal(viewGame(declared,f.actor).bribePromiseGuide,null);
|
||||
const counter=viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? [];
|
||||
assert.ok(counter.includes(f.counter));
|
||||
const canceled=act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
|
||||
const resumed=advance(canceled,clean);
|
||||
assert.deepEqual({simple:resumed.bribePromises,grouped:resumed.bribeExpressionPromises,mixed:resumed.mixedBribeQuestions},prior);
|
||||
assert.equal(resumed.karamaShipping?.owner,f.actor); assert.ok(held(resumed,f.reactor,f.kull));
|
||||
const guide=viewGame(resumed,f.actor).bribePromiseGuide; assert.ok(guide);
|
||||
const paid=act(resumed,f.actor,guide.action);
|
||||
assert.equal(player(paid,f.recipient).bribes,3);
|
||||
assert.deepEqual(inventory(paid),f.originalCards);
|
||||
const successful=advance(declared,clean);
|
||||
assert.ok(held(successful,f.actor,f.karama)); assert.equal(successful.karamaShipping,null);
|
||||
assert.deepEqual({simple:successful.bribePromises,grouped:successful.bribeExpressionPromises,mixed:successful.mixedBribeQuestions},prior);
|
||||
const required=viewGame(successful,f.actor).bribePromiseGuide; assert.ok(required);
|
||||
const paidAfterSuccess=act(successful,f.actor,required.action);
|
||||
assert.equal(player(paidAfterSuccess,f.recipient).bribes,3);
|
||||
assert.deepEqual(inventory(paidAfterSuccess),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
for (const operator of ['and','or'] as const) {
|
||||
void test(`two-recipient ${operator.toUpperCase()} bribe answer preserves its full expression through a real Kull counter`,()=>{
|
||||
const f=fixture();
|
||||
let game=act(f.game,f.asker,{type:'card',card:f.truth});
|
||||
for (let count=0; game.truthtrance?.stage==='priority' && count<game.players.length; count++) {
|
||||
const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
|
||||
game=act(game,seat.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
const claim={kind:operator,terms:[
|
||||
{kind:'payment',recipient:f.recipient,minimum:2},
|
||||
{kind:'payment',recipient:f.counterOwner,minimum:1},
|
||||
]};
|
||||
game=act(game,f.asker,{type:'truthAsk',question:{kind:'bribeExpression',target:f.actor,turn:game.turn,claim}});
|
||||
assert.ok(viewGame(game,f.actor).truthBribeExpressionAnswers?.includes('yes'));
|
||||
game=act(game,f.actor,{type:'truthAnswer',answer:'yes'});
|
||||
const ledger=structuredClone(game.bribeExpressionPromises);
|
||||
assert.deepEqual(ledger?.at(-1)?.claim,claim);
|
||||
const purse=player(game,f.actor).spice;
|
||||
const pending=offer(f,game).game;
|
||||
assert.equal(viewGame(pending,f.actor).bribePromiseGuide,null);
|
||||
assert.equal(player(pending,f.actor).spice,purse);
|
||||
const declared=declare(f,pending);
|
||||
assert.equal(viewGame(declared,f.actor).bribePromiseGuide,null);
|
||||
const counters=viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? [];
|
||||
assert.ok(counters.includes(f.counter));
|
||||
const canceled=act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
|
||||
game=advance(canceled,clean);
|
||||
assert.deepEqual(game.bribeExpressionPromises,ledger);
|
||||
assert.equal(player(game,f.actor).spice,purse);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
const paidTo:string[]=[];
|
||||
for (let count=0; count<3 && !game.bribeExpressionPromises?.at(-1)?.fulfilled; count++) {
|
||||
const guide=viewGame(game,f.actor).bribePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'bribe');
|
||||
assert.ok(guide.action.target===f.recipient || guide.action.target===f.counterOwner);
|
||||
paidTo.push(guide.action.target as string);
|
||||
game=act(game,f.actor,guide.action);
|
||||
}
|
||||
assert.ok(game.bribeExpressionPromises?.at(-1)?.fulfilled);
|
||||
if (operator==='and') {
|
||||
assert.ok(paidTo.includes(f.recipient) && paidTo.includes(f.counterOwner));
|
||||
assert.ok(player(game,f.recipient).bribes>=2);
|
||||
assert.ok(player(game,f.counterOwner).bribes>=1);
|
||||
} else {
|
||||
assert.equal(new Set(paidTo).size,1,'An OR may complete with either one native payment');
|
||||
assert.ok(player(game,f.recipient).bribes>=2 || player(game,f.counterOwner).bribes>=1);
|
||||
}
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
void test('an active Kull frame owns its original bribe ledger and never exposes a guide',()=>{
|
||||
const f=fixture(), bound=bind(f,f.game,'yes').game, pending=offer(f,bound).game;
|
||||
const reaction=viewGame(pending,f.reactor).kullReaction; assert.ok(reaction);
|
||||
const changed=structuredClone(pending); assert.ok(changed.bribePromises?.length);
|
||||
changed.bribePromises[0].progress++;
|
||||
reject(changed,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
|
||||
assert.equal(viewGame(pending,f.actor).bribePromiseGuide,null);
|
||||
});
|
||||
|
||||
void test('neutral Kull offer is identical for opponents regardless of held Kull',()=>{
|
||||
const f=fixture(), bound=bind(f,f.game,'yes').game, absent=structuredClone(bound), owner=player(absent,f.reactor);
|
||||
// Controlled conserved swap: replace held Kull with an original undealt deck
|
||||
// card. This is not an earned trade or a naturally selected deal.
|
||||
const i=owner.hand.findIndex(card=>card.id===f.kull); assert.ok(i>=0 && absent.deck.length);
|
||||
[owner.hand[i],absent.deck[0]]=[absent.deck[0],owner.hand[i]];
|
||||
assert.deepEqual(inventory(absent),f.originalCards);
|
||||
const withCard=act(bound,f.actor,attempt(f)), withoutCard=act(absent,f.actor,attempt(f));
|
||||
assert.deepEqual(viewGame(withCard,f.actor).kullReaction,viewGame(withoutCard,f.actor).kullReaction);
|
||||
assert.equal(viewGame(withoutCard,f.actor).bribePromiseGuide,null);
|
||||
});
|
||||
|
||||
void test('all four legal profiles can answer a Kull-game bribe question',()=>{
|
||||
const f=fixture(), asked=ask(f,f.game);
|
||||
for(const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(asked,f.actor); view.players.find(row=>row.id===f.actor)!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action,difficulty); assert.equal(action.type,'truthAnswer');
|
||||
assert.ok(view.truthBribeAnswers?.includes(action.answer as 'yes'|'no'));
|
||||
const game=act(asked,f.actor,action); assert.deepEqual(inventory(game),f.originalCards);
|
||||
if (action.answer === 'yes') {
|
||||
const own=viewGame(game,f.actor); own.players.find(row=>row.id===f.actor)!.bot=difficulty;
|
||||
const guidance=own.bribePromiseGuide; assert.ok(guidance);
|
||||
const payment=botActions(own)[0]; assert.deepEqual(payment,guidance.action);
|
||||
assert.ok(act(game,f.actor,payment).bribePromises?.at(-1)?.fulfilled);
|
||||
}
|
||||
const offered=act(game,f.actor,attempt(f)), choam=viewGame(offered,f.reactor);
|
||||
choam.players.find(row=>row.id===f.reactor)!.bot=difficulty;
|
||||
const response=botActions(choam)[0]; assert.ok(response); assert.equal(response.type,'kullDecision');
|
||||
assert.deepEqual(inventory(act(offered,f.reactor,response)),f.originalCards);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { bribePromiseModeSupported } from '../game/bribe-promises';
|
||||
import { bribeExpressionPromiseModeSupported } from '../game/bribe-expression-promises';
|
||||
import { bribeTimingBlock } from '../game/bribe-options';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { act, createPublicChoamKullFixture, publicKullAttempt } from './fixture-public-choam-kull';
|
||||
|
||||
const basic = {
|
||||
advanced: false,
|
||||
expansions: ['choam', 'ix'],
|
||||
players: [{faction:'choam'}, {faction:'emperor'}, {faction:'guild'}],
|
||||
};
|
||||
|
||||
void test('Basic printed Kull supports the shared bribe family only in its original public envelope', () => {
|
||||
for (const quote of [bribePromiseModeSupported, bribeExpressionPromiseModeSupported]) {
|
||||
assert.equal(quote({...basic,kullPreview:true}),true);
|
||||
assert.equal(quote({...basic,expansions:['ix','choam'],kullPreview:true}),true);
|
||||
assert.equal(quote({...basic,kullPreview:false}),true);
|
||||
assert.equal(quote({...basic,advanced:true,kullPreview:true}),false);
|
||||
assert.equal(quote({...basic,expansions:['choam'],kullPreview:true}),false);
|
||||
assert.equal(quote({...basic,expansions:['choam','ix','ecaz'],kullPreview:true}),false);
|
||||
assert.equal(quote({...basic,players:[{faction:'emperor'},{faction:'guild'}],kullPreview:true}),false);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'ixians'}],kullPreview:true}),false);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'emperor'},{faction:'emperor'}],kullPreview:true}),false);
|
||||
for (const option of ['semutaPreview','nexusKullPreview','richeseBetrayalPreview','guildBetrayalPreview',
|
||||
'nexusIxianReplacementPreview','nexusIxianBetrayalPreview','nexusHarkonnenBetrayalPreview',
|
||||
'moritaniAssassinatePreview','mobileStronghold','nexusCards','homeworlds','techTokens',
|
||||
'leaderSkills','discoveryEnabled','ecazTreachery'] as const)
|
||||
assert.equal(quote({...basic,kullPreview:true,[option]:true}),false,option);
|
||||
// Existing unmarked admission is unchanged even for legacy fields not
|
||||
// previously considered by the bribe predicate.
|
||||
assert.equal(quote({...basic,guildBetrayalPreview:true}),true);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a real neutral Kull offer temporarily blocks bribes and the original timing resumes on decline', () => {
|
||||
const fixture = createPublicChoamKullFixture();
|
||||
assert.equal(bribeTimingBlock(fixture.game),null);
|
||||
const offered = act(fixture.game,fixture.actor,publicKullAttempt(fixture));
|
||||
const reaction = viewGame(offered,fixture.reactor).kullReaction; assert.ok(reaction);
|
||||
assert.equal(offered.pendingKull?.stage,'offer');
|
||||
assert.match(bribeTimingBlock(offered) ?? '',/Finish the current decision or response/);
|
||||
assert.match(viewGame(offered,fixture.actor).bribeOptions.blocked ?? '',/Finish the current decision or response/);
|
||||
const resumed = act(offered,fixture.reactor,{type:'kullDecision',event:reaction.event,decline:true});
|
||||
assert.equal(resumed.pendingKull,null);
|
||||
assert.equal(bribeTimingBlock(resumed),null);
|
||||
});
|
||||
@@ -44,7 +44,7 @@ void test('Kull is a host-selected lobby preview and default Basic Truth promise
|
||||
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.equal(bribePromiseModeSupported(f.setup),true,'Basic printed Kull now supports the structured bribe family');
|
||||
assert.ok(f.actions.some(row => row.actor === f.lobby.host && row.action.type === 'start'));
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user