Support bribe promises with Kull and Richese
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
52d932002b
commit
2eaaab876e
15 files changed
+422
-10
No files matched your search
@@ -2254,7 +2254,7 @@ export function GameTable({
|
||||
</> : <>
|
||||
Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules.
|
||||
Combined turn-action Truthtrance questions are unavailable.
|
||||
When Richese is seated, alliance, Revival and bribe commitments are also unavailable,
|
||||
When Richese is seated, alliance and Revival commitments are also unavailable,
|
||||
and some Kull reactions may be unavailable in complex response windows.
|
||||
</>}{' '}
|
||||
Unfunded non-CHOAM Karama-dependent bids remain deferred. This does not represent complete CHOAM rules.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Kull Wahad: source limits and Karama interruption design
|
||||
|
||||
## Basic bribe answers with Richese — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Ordinary, grouped and mixed bribe Truthtrance answers
|
||||
can coexist with printed Kull and Richese's Semuta in the supported bare Basic
|
||||
profile. The physical Truthtrance discard retains its bribe binding through
|
||||
Semuta's claim or pass. A later Kull success or distinct counter likewise
|
||||
preserves the answer through its Semuta reaction; the real payment and No cap
|
||||
remain governed by the original bribe rules. The preview's existing timing is
|
||||
provisional. Revival and alliance questions with Richese, whole-turn questions,
|
||||
special-cache Kull and broader module combinations remain separate.
|
||||
[Runtime evidence](../tests/kull-richese-bribe-runtime.test.ts),
|
||||
[Truth discard evidence](../tests/kull-richese-bribe-truth-semuta.test.ts),
|
||||
[rendered controls](../tests/kull-richese-bribe-table-controls.test.tsx).
|
||||
|
||||
## Basic mixed current-lot answers with Richese — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Mixed current-fact and ordinary-lot bid questions
|
||||
|
||||
@@ -17,6 +17,18 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic Kull, Richese and bribe Truthtrance — 29 October. Prototyped / Partial.**
|
||||
Ordinary, grouped and mixed bribe answers now work in the supported bare Basic
|
||||
Kull/Richese profile. The payment binding survives the physical Truthtrance
|
||||
discard's Semuta claim or pass and later Kull success or a distinct counter,
|
||||
including the respective Semuta response. Twelve related files pass 63 cases,
|
||||
with genuine setup, cache purchase, answer, reaction, payment and minimal bot
|
||||
actions; a separate rendered table/control case passes. Typecheck, lint and the
|
||||
production build pass. Alliance and Revival
|
||||
questions with Richese, whole-turn questions,
|
||||
special-cache Kull and broader profiles remain unfinished.
|
||||
[Scope](CHOAM_KULL_DESIGN.md#basic-bribe-answers-with-richese--29-october-2026).
|
||||
|
||||
**Staged joint group commitments — 29 October. Prototyped / Partial.**
|
||||
The joint planner can retain matching group membership or quote a member's own
|
||||
departure, including payment to a former ally after leaving. An invitation
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic Kull and Richese bribe answers — 29 October 2026
|
||||
|
||||
The supported bare Basic profile retains ordinary bribe answers through
|
||||
Semuta and printed Kull continuations. The existing online ordering remains
|
||||
provisional. [Scope](CHOAM_KULL_DESIGN.md#basic-bribe-answers-with-richese--29-october-2026).
|
||||
|
||||
## Group admission with joint commitments — 29 October 2026
|
||||
|
||||
Staged Basic admission can retain a matching group alliance answer alongside
|
||||
|
||||
@@ -38,7 +38,7 @@ export function parseBribeClaim(value: unknown): BribeClaim {
|
||||
|
||||
/** The ordinary, grouped and mixed bribe families share this admission. The
|
||||
* printed Kull extension is deliberately limited to the existing fresh Basic
|
||||
* CHOAM, Ixian, Tleilaxu, Ecaz, Moritani and classic roster; other previews keep their own composition boundaries.
|
||||
* CHOAM, Richese, Ixian, Tleilaxu, Ecaz, Moritani and 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;
|
||||
@@ -49,8 +49,11 @@ export function bribePromiseModeSupported(game: NativePromiseScope & {
|
||||
}, options: {allowKullRichese?: boolean} = {}): boolean {
|
||||
if (!nativePromiseScopeSupported(game) || game.nexusKullPreview ||
|
||||
game.richeseBetrayalPreview) return false;
|
||||
if (game.semutaPreview && game.kullPreview) return game.semutaPreview === true &&
|
||||
game.kullPreview === true && game.players.some(player => player.faction === 'richese') &&
|
||||
bribePromiseModeSupported({...game, semutaPreview: undefined}, {allowKullRichese:true});
|
||||
if (game.semutaPreview) return game.semutaPreview === true && !game.advanced &&
|
||||
!game.kullPreview && game.expansions.includes('choam') &&
|
||||
game.expansions.includes('choam') &&
|
||||
game.players.some(player => player.faction === 'richese') &&
|
||||
!game.guildBetrayalPreview && !game.nexusIxianReplacementPreview &&
|
||||
!game.nexusIxianBetrayalPreview && !game.nexusHarkonnenBetrayalPreview &&
|
||||
|
||||
+1
-1
@@ -9349,7 +9349,7 @@ function semutaOfferSupported(
|
||||
continuation.auctionBinding?.kind === 'richese';
|
||||
if (continuation.kind === 'truthtranceDiscard' &&
|
||||
['bribe', 'bribeExpression', 'mixedBribe'].includes(continuation.record.question.kind) &&
|
||||
(!continuation.bribeBinding || !bareBasicSemutaProfile(g) || !!g.kullPreview)) return false;
|
||||
(!continuation.bribeBinding || !bareBasicSemutaProfile(g) || !bribePromiseModeSupported(g))) return false;
|
||||
const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard';
|
||||
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
|
||||
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
|
||||
|
||||
@@ -14,7 +14,7 @@ export const FACTION_RULES: Record<
|
||||
'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.',
|
||||
'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.',
|
||||
'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.',
|
||||
'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; simple and mixed current-lot bid questions and phase-wide bid questions are supported, while Revival, alliance and bribe commitments are unavailable. Combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.',
|
||||
'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; ordinary bribe, simple and mixed current-lot bid, and phase-wide bid questions are supported, while Revival and alliance commitments remain unavailable. Combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.',
|
||||
],
|
||||
advanced: [
|
||||
'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.',
|
||||
|
||||
+3
-3
@@ -3515,7 +3515,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The payer spends spice when the actual native bribe is accepted. The recipient keeps that payment deferred until the next Mentat opening; a late Mentat payment waits for next turn. The question creates no immediate recipient cash, discount or refund. Current auction and sealed-battle liabilities stay reserved.',
|
||||
'Ordinary payment witnesses use current spendable spice, CHOAM’s own automatic opening receipt once, and one remaining owned Charity claim after prepayments. The automatic receipt and ordinary claim have separate markers; guides spend only actual cash. Later installments share the final purse with battle, bid, revival, shipment and movement commitments. Actual counters or opposing claims can change funding. Other future income, pledge withdrawal, gifts and wider card preparation remain bounded. A later question must preserve earlier answers too.',
|
||||
'Your persistent panel shows named cumulative progress and fulfilled/released status. Its legal ordinary-payment guide works out of turn in a clear native window. Current payment controls also apply the recipient cap and retain funding for other Yes answers. Stale or modified guides reject.',
|
||||
'In supported bare Basic Richese games, an answered bribe question retains its payment commitment while the used Truthtrance passes through the Semuta claim-or-pass opportunity. The same continuation applies to combined and mixed bribe questions. Kull and optional module combinations remain outside this preview.',
|
||||
'In supported bare Basic Richese games, including the printed Kull preview, an answered bribe question retains its payment commitment while the used Truthtrance passes through the Semuta claim-or-pass opportunity. The same continuation applies to combined and mixed bribe questions. Optional module combinations remain outside this preview.',
|
||||
'Your own voluntary spending, alliance offer to the recipient or finishing Mentat cannot evade an unpaid Yes. A new Yes reopens an already-ready Charity or Mentat respondent; Collection readiness stays. A known automatic Inflation Double closure requires payment before Collection Ready ends that legal window. Completed opposing consequences may release impossible answers in accepted order. No finishes at the original turn boundary.',
|
||||
'Quiet Mentat remains available after opening collection; Inflation Double still prohibits actual payments. No can be fixed under Double, while Yes requires a currently legal funded payment. This cumulative funded-current-turn protocol is a provisional composition of general Truthtrance authority; optional modules, future preparations, broader deals and complete-mode acceptance remain due.',
|
||||
],
|
||||
@@ -4861,7 +4861,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 a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. Basic supports Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. In an Advanced preview lobby, the host may explicitly select printed Kull with CHOAM, classic factions and exactly the CHOAM and Ix decks, without optional modules. Advanced supports Bene Gesserit substitutions and legal special powers. Both previews withhold some structured questions and defer unfunded non-CHOAM Karama-dependent bids. Existing games are not retrofitted, and this warning does not certify complete rules.',
|
||||
'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid questions and phase-wide bid questions are available, including with Richese and Semuta. Without Richese, supported Revival, alliance and bribe questions are also available. A pending Kull response holds their controls while the original transaction resolves. Combined turn-action questions and wider interactions remain incomplete.',
|
||||
'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid and ordinary bribe questions are available, including with Richese and Semuta. Without Richese, supported Revival and alliance questions are also available. A pending Kull response holds their controls while the original transaction resolves. 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.',
|
||||
@@ -4876,7 +4876,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: 'Selectable 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. Simple and mixed current-lot bid commitments and phase-wide bid commitments can coexist with this preview including Richese; Revival, alliance and bribe commitments remain limited to the non-Richese profile. Deferred overbid recovery, 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-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-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: 'Implementation', status: 'Partial', detail: 'Selectable 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. Simple and mixed current-lot bid, phase-wide bid and ordinary bribe commitments can coexist with this preview including Richese; Revival and alliance commitments remain limited to the non-Richese profile. Deferred overbid recovery, 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-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', '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'] },
|
||||
|
||||
@@ -27,6 +27,9 @@ void test('Basic printed Kull supports the shared bribe family in its declared n
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'moritani'}],expansions:['choam','ix','ecaz'],kullPreview:true}),true);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'ecaz'}],kullPreview:true}),false);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'richese'}],kullPreview:true}),false);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'richese'}],kullPreview:true,semutaPreview:true}),true);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'richese'}],kullPreview:true,semutaPreview:true,advanced:true}),false);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'richese'}],kullPreview:true,semutaPreview:true,homeworlds: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',
|
||||
|
||||
@@ -75,7 +75,7 @@ void test('phase-wide Kull questions admit the supported native Richese and Semu
|
||||
semutaPreview:true, players:[{faction:'choam'}, {faction:'richese'}]};
|
||||
assert.equal(phaseBidPromiseModeSupported(scope), true);
|
||||
assert.equal(bidPromiseModeSupported(scope), false);
|
||||
assert.equal(bribePromiseModeSupported(scope), false);
|
||||
assert.equal(bribePromiseModeSupported(scope), true);
|
||||
});
|
||||
|
||||
void test('phase-wide Yes survives a real Kull interception and completes its original bid', () => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** Genuine public Basic start. Only the original unopened deal and a safe
|
||||
* unopened spice position are selected. The question, Richese cache purchase,
|
||||
* Kull response, Semuta reaction and payment all use actual actions. */
|
||||
function fixture(answer:'yes'|'no' = 'yes') {
|
||||
let game = createGame('KULLRICHBRIBE',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
|
||||
for (const id of ['richese','emperor','harkonnen','guild'] as const)
|
||||
joinGame(game,newPlayer(id,id,id));
|
||||
game = act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
|
||||
for (const seat of game.players) game = act(game,seat.id,{type:'ready'});
|
||||
game = act(game,'choam',{type:'start'});
|
||||
assert.equal(game.status,'setup'); assert.ok(game.kullPreview && game.semutaPreview);
|
||||
const originalCards = inventory(game), selected:Game['deck'] = [];
|
||||
// Harkonnen receives two cards before Guild's one.
|
||||
for (const kind of ['kull','filler','karama','karama','filler','truth'] as const) {
|
||||
const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' :
|
||||
kind === 'truth' ? card.effect === 'truthtrance' : kind === 'karama' ? card.effect === 'karama' :
|
||||
card.kind === 'worthless' && card.id !== 'ix-kull-wahad');
|
||||
assert.ok(index >= 0); selected.push(game.deck.splice(index,1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
game = advance(game,state => state.status === 'playing');
|
||||
const [kull,,karama,counter,,truth] = selected.map(card=>card.id);
|
||||
for (const [id,card] of [['choam',kull],['emperor',karama],['harkonnen',counter],['guild',truth]] as const)
|
||||
assert.ok(player(game,id).hand.some(row=>row.id===card),`${id} received ${card}`);
|
||||
const spice = game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(spice>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(spice,1)[0]);
|
||||
game = advance(game,state => state.phase===2 && clean(state) && state.choamCharity?.turn===state.turn);
|
||||
game = act(game,'guild',{type:'card',card:truth});
|
||||
while (game.truthtrance?.stage==='priority') {
|
||||
const next=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(next);
|
||||
game=act(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
game=act(game,'guild',{type:'truthAsk',question:{kind:'bribe',target:'emperor',turn:game.turn,
|
||||
recipient:'guild',minimum:2}});
|
||||
const answers=viewGame(game,'emperor').truthBribeAnswers;
|
||||
assert.ok(answers?.includes(answer));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(game,'emperor'); view.players.find(row=>row.id==='emperor')!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.equal(action.type,'truthAnswer',difficulty);
|
||||
assert.ok(view.truthBribeAnswers?.includes(action.answer as 'yes'|'no'),difficulty);
|
||||
assert.doesNotThrow(()=>act(game,'emperor',action),difficulty);
|
||||
}
|
||||
game=act(game,'emperor',{type:'truthAnswer',answer});
|
||||
const commitment=game.bribePromises!.find(row=>row.player==='emperor')!;
|
||||
game=advance(game,state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) &&
|
||||
!!state.auction && state.auction.active==='emperor' && clean(state));
|
||||
assert.ok(game.bribePromises?.some(row=>row.event===commitment.event && !row.fulfilled && !row.released));
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
return {game,kull,karama,counter,event:commitment.event,originalCards};
|
||||
}
|
||||
|
||||
function declare(f:ReturnType<typeof fixture>) {
|
||||
let game=act(f.game,'harkonnen',{type:'card',mode:'purchase',card:f.counter});
|
||||
assert.equal(game.pendingKull?.stage,'offer');
|
||||
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(row=>row.card.id===f.kull && !row.blocked));
|
||||
game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
|
||||
assert.equal(game.pendingKull?.stage,'counter');
|
||||
return game;
|
||||
}
|
||||
function resolveSemuta(game:Game,card:string,claim:boolean):Game {
|
||||
const pending=game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.reaction?.stage,'offer');
|
||||
assert.equal(pending.batch.entries[0].card.id,card);
|
||||
const event=pending.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(game,'richese'); view.players.find(row=>row.id==='richese')!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.ok(['semutaPass','semutaCommit'].includes(action.type),difficulty);
|
||||
assert.doesNotThrow(()=>act(game,'richese',action),difficulty);
|
||||
}
|
||||
if (claim) {
|
||||
assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true);
|
||||
game=act(game,'richese',{type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage==='select')
|
||||
game=act(game,'richese',{type:'semutaSelect',event,card});
|
||||
} else {
|
||||
for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count<game.players.length;count++) {
|
||||
const reaction=game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage==='offer');
|
||||
const next=game.players.find(row=>!reaction.passed.includes(row.id)); assert.ok(next);
|
||||
game=act(game,next.id,{type:'semutaPass',event});
|
||||
}
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
|
||||
return game;
|
||||
}
|
||||
for (const claim of [false,true]) void test(`Basic bribe Yes survives Kull success and Semuta ${claim?'claim':'pass'}`,()=>{
|
||||
const f=fixture();
|
||||
let game=declare(f);
|
||||
assert.equal(viewGame(game,'emperor').bribePromiseGuide,null);
|
||||
game=advance(game,state=>!!state.pendingTreacheryDiscard);
|
||||
assert.ok(game.bribePromises?.some(row=>row.event===f.event && !row.fulfilled && !row.released));
|
||||
game=resolveSemuta(game,f.kull,claim);
|
||||
game=advance(game,clean);
|
||||
assert.ok(game.bribePromises?.some(row=>row.event===f.event && !row.fulfilled && !row.released));
|
||||
const guide=viewGame(game,'emperor').bribePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,'guild');
|
||||
game=act(game,'emperor',guide.action);
|
||||
assert.ok(game.bribePromises?.some(row=>row.event===f.event && row.fulfilled));
|
||||
assert.equal(player(game,'guild').bribes,2);
|
||||
assert.equal(player(game,'richese').hand.some(card=>card.id===f.kull),claim);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
for (const claim of [false,true]) void test(`Basic bribe Yes survives Kull counter and Semuta ${claim?'claim':'pass'}`,()=>{
|
||||
const f=fixture();
|
||||
let game=declare(f);
|
||||
assert.ok(viewGame(game,'emperor').responseControls?.cancelCards?.includes(f.karama));
|
||||
game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama});
|
||||
game=resolveSemuta(game,f.karama,claim);
|
||||
game=advance(game,clean);
|
||||
assert.ok(game.bribePromises?.some(row=>row.event===f.event && !row.fulfilled && !row.released));
|
||||
const guide=viewGame(game,'emperor').bribePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,'guild');
|
||||
game=act(game,'emperor',guide.action);
|
||||
assert.ok(game.bribePromises?.some(row=>row.event===f.event && row.fulfilled));
|
||||
assert.equal(player(game,'guild').bribes,2);
|
||||
assert.equal(player(game,'richese').hand.some(card=>card.id===f.karama),claim);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test('Basic bribe No retains its cap through Kull and Semuta',()=>{
|
||||
const f=fixture('no'); let game=declare(f);
|
||||
game=advance(game,state=>!!state.pendingTreacheryDiscard);
|
||||
game=resolveSemuta(game,f.kull,false); game=advance(game,clean);
|
||||
const before=structuredClone(game);
|
||||
assert.throws(()=>act(game,'emperor',{type:'bribe',target:'guild',amount:2} as Action));
|
||||
assert.deepEqual(game,before);
|
||||
game=act(game,'emperor',{type:'bribe',target:'guild',amount:1});
|
||||
assert.equal(player(game,'guild').bribes,1);
|
||||
assert.ok(!game.bribePromises?.find(row=>row.event===f.event)?.released);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { alliancePromiseModeSupported } from '../game/alliance-promises';
|
||||
import { bidPromiseModeSupported, currentBidPromiseModeSupported } from '../game/bid-promises';
|
||||
import { bribeExpressionPromiseModeSupported } from '../game/bribe-expression-promises';
|
||||
import { bribePromiseModeSupported } from '../game/bribe-promises';
|
||||
import { revivalPromiseModeSupported } from '../game/revival-promises';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { createKullRicheseSemutaFixture } from './fixture-kull-richese-semuta';
|
||||
|
||||
const profile = {
|
||||
advanced: false, expansions: ['choam','ix'], kullPreview: true, semutaPreview: true,
|
||||
players: [{faction:'choam'}, {faction:'richese'}, {faction:'emperor'}, {faction:'harkonnen'}],
|
||||
};
|
||||
|
||||
void test('Basic Kull and Semuta expose the bribe family only in the supported native profile', () => {
|
||||
for (const quote of [bribePromiseModeSupported, bribeExpressionPromiseModeSupported]) {
|
||||
assert.equal(quote(profile), true);
|
||||
assert.equal(quote({...profile, expansions:['ix','choam']}), true);
|
||||
assert.equal(quote({...profile, expansions:['choam','ix','ecaz'],
|
||||
players:[...profile.players, {faction:'moritani'}, {faction:'ecaz'}]}), true);
|
||||
for (const unsupported of [
|
||||
{...profile, advanced:true},
|
||||
{...profile, expansions:['choam']},
|
||||
{...profile, expansions:['choam','ix','ix']},
|
||||
{...profile, players:profile.players.filter(player => player.faction !== 'choam')},
|
||||
{...profile, players:profile.players.filter(player => player.faction !== 'richese')},
|
||||
{...profile, players:[...profile.players, {faction:'ecaz'}]},
|
||||
{...profile, players:[...profile.players, {faction:'richese'}]},
|
||||
]) assert.equal(quote(unsupported), false);
|
||||
for (const key of ['homeworlds','nexusCards','leaderSkills','techTokens','strongholdCards',
|
||||
'discoveryEnabled','nexusKullPreview','richeseBetrayalPreview','guildBetrayalPreview',
|
||||
'nexusIxianReplacementPreview','nexusIxianBetrayalPreview','nexusHarkonnenBetrayalPreview',
|
||||
'moritaniAssassinatePreview'] as const)
|
||||
assert.equal(quote({...profile,[key]:true}),false,key);
|
||||
}
|
||||
});
|
||||
|
||||
void test('admitting bribes does not silently enable unrelated Semuta promise families', () => {
|
||||
assert.equal(alliancePromiseModeSupported(profile),false);
|
||||
assert.equal(revivalPromiseModeSupported(profile),false);
|
||||
assert.equal(bidPromiseModeSupported(profile),false);
|
||||
assert.equal(currentBidPromiseModeSupported(profile),true);
|
||||
});
|
||||
|
||||
void test('a genuine public Basic Kull and Richese start projects the ordinary bribe controls', () => {
|
||||
const {game} = createKullRicheseSemutaFixture();
|
||||
assert.equal(game.kullPreview,true);
|
||||
assert.equal(game.semutaPreview,true);
|
||||
const view = viewGame(game,'emperor');
|
||||
assert.ok(view.truthBribeTargets.includes('harkonnen'));
|
||||
assert.ok(view.truthBribeExpressionTargets.includes('harkonnen'));
|
||||
assert.ok(view.truthMixedBribeTargets.includes('harkonnen'));
|
||||
assert.deepEqual(view.truthAllianceTargets,[]);
|
||||
assert.deepEqual(view.truthRevivalTargets,[]);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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 { createGame, joinGame, newPlayer, viewGame, type Action, type Game, type GameView } from '../game/engine';
|
||||
import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
const { Truthtrance } = await import('../components/truthtrance');
|
||||
const { BribePromiseGuideButton } = await import('../components/bribe-promises');
|
||||
aliases.deregister();
|
||||
|
||||
const table = (game:GameView) => renderToStaticMarkup(createElement(GameTable,
|
||||
{game,busy:false,send:async () => {},onExit() {}}));
|
||||
const truth = (game:GameView) => renderToStaticMarkup(createElement(Truthtrance,
|
||||
{game,busy:false,act() {}}));
|
||||
|
||||
/** Native lobby/start and actions; only original unopened deal and Spice
|
||||
* positions are selected. The question is at a real settled Charity opening. */
|
||||
function fixture() {
|
||||
let game=createGame('KULLRICHTABLE',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
|
||||
for (const id of ['richese','emperor','harkonnen','guild'] as const) joinGame(game,newPlayer(id,id,id));
|
||||
game=act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
|
||||
for (const seat of game.players) game=act(game,seat.id,{type:'ready'});
|
||||
game=act(game,'choam',{type:'start'});
|
||||
assert.equal(game.status,'setup'); assert.ok(game.kullPreview && game.semutaPreview);
|
||||
const originalCards=inventory(game), selected:Game['deck']=[];
|
||||
for (const kind of ['kull','filler','karama','karama','filler','truth'] as const) {
|
||||
const index=game.deck.findIndex(card=>kind==='kull' ? card.id==='ix-kull-wahad' :
|
||||
kind==='truth' ? card.effect==='truthtrance' : kind==='karama' ? card.effect==='karama' :
|
||||
card.kind==='worthless' && card.id!=='ix-kull-wahad');
|
||||
assert.ok(index>=0); selected.push(game.deck.splice(index,1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
game=advance(game,state=>state.status==='playing');
|
||||
const truthCard=selected[5].id;
|
||||
assert.ok(player(game,'guild').hand.some(card=>card.id===truthCard));
|
||||
const spice=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(spice>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(spice,1)[0]);
|
||||
game=advance(game,state=>state.phase===2 && clean(state) && state.choamCharity?.turn===state.turn);
|
||||
game=act(game,'guild',{type:'card',card:truthCard});
|
||||
while (game.truthtrance?.stage==='priority') {
|
||||
const next=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(next);
|
||||
game=act(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
return {game,originalCards};
|
||||
}
|
||||
|
||||
void test('Basic Kull and Richese render the real bribe question, answer and subsequent payment guide',()=>{
|
||||
const f=fixture();
|
||||
let game=f.game;
|
||||
const asker=viewGame(game,'guild');
|
||||
assert.ok(asker.truthBribeTargets.includes('emperor'));
|
||||
assert.match(truth(asker), /<option value="bribe">Bind ordinary bribe payments this turn<\/option>/);
|
||||
assert.match(table(asker), /Bind ordinary bribe payments this turn/);
|
||||
|
||||
game=act(game,'guild',{type:'truthAsk',question:{kind:'bribe',target:'emperor',turn:game.turn,
|
||||
recipient:'guild',minimum:2}});
|
||||
const respondent=viewGame(game,'emperor');
|
||||
assert.ok(respondent.truthBribeAnswers?.includes('yes'));
|
||||
assert.ok(respondent.truthBribeAnswers?.includes('no'));
|
||||
assert.equal(viewGame(game,'guild').truthBribeAnswers,null);
|
||||
assert.match(truth(respondent),/Answer Yes/);
|
||||
assert.match(truth(respondent),/Answer No/);
|
||||
assert.match(table(respondent),/Answer Yes/);
|
||||
assert.match(table(respondent),/Answer No/);
|
||||
|
||||
game=act(game,'emperor',{type:'truthAnswer',answer:'yes'});
|
||||
game=advance(game,clean);
|
||||
const own=viewGame(game,'emperor'), sent:Action[]=[];
|
||||
assert.ok(own.bribePromiseGuide);
|
||||
assert.match(table(own),/Pay 2 spice to guild while honoring your answers/i);
|
||||
const button=BribePromiseGuideButton({game:own,busy:false,send(action) {sent.push(action);}});
|
||||
assert.ok(button); assert.equal(button.props.disabled,false); button.props.onClick();
|
||||
assert.deepEqual(sent,[own.bribePromiseGuide.action]);
|
||||
game=act(game,'emperor',sent[0]);
|
||||
assert.ok(game.bribePromises?.some(row=>row.player==='emperor' && row.fulfilled));
|
||||
assert.equal(player(game,'guild').bribes,2);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** Only the original unopened deal and a safe unopened Spice position are
|
||||
* selected. Richese actually purchases Semuta in the native cache auction,
|
||||
* then Guild asks its original physically held Truthtrance during Bidding. */
|
||||
function fixture() {
|
||||
let game=createGame('KULLRICHTRUTH',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
|
||||
for (const id of ['richese','emperor','harkonnen','guild'] as const) joinGame(game,newPlayer(id,id,id));
|
||||
game=act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
|
||||
for (const seat of game.players) game=act(game,seat.id,{type:'ready'});
|
||||
game=act(game,'choam',{type:'start'});
|
||||
assert.equal(game.status,'setup'); assert.ok(game.kullPreview && game.semutaPreview);
|
||||
const originalCards=inventory(game), selected:Game['deck']=[];
|
||||
for (const kind of ['kull','filler','karama','karama','filler','truth'] as const) {
|
||||
const index=game.deck.findIndex(card=>kind==='kull' ? card.id==='ix-kull-wahad' :
|
||||
kind==='truth' ? card.effect==='truthtrance' : kind==='karama' ? card.effect==='karama' :
|
||||
card.kind==='worthless' && card.id!=='ix-kull-wahad');
|
||||
assert.ok(index>=0); selected.push(game.deck.splice(index,1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
game=advance(game,state=>state.status==='playing');
|
||||
const truth=selected[5].id;
|
||||
assert.ok(player(game,'guild').hand.some(card=>card.id===truth));
|
||||
const spice=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(spice>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(spice,1)[0]);
|
||||
game=advance(game,state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) &&
|
||||
!!state.auction && state.auction.active==='emperor' && clean(state));
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
return {game,truth,originalCards};
|
||||
}
|
||||
function resolveSemuta(game:Game,card:string,claim:boolean):Game {
|
||||
const pending=game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.reaction?.stage,'offer');
|
||||
assert.ok(pending.batch.entries.some(entry=>entry.card.id===card));
|
||||
const event=pending.batch.event;
|
||||
if (claim) {
|
||||
assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true);
|
||||
game=act(game,'richese',{type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage==='select')
|
||||
game=act(game,'richese',{type:'semutaSelect',event,card});
|
||||
} else {
|
||||
for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count<game.players.length;count++) {
|
||||
const reaction=game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage==='offer');
|
||||
const next=game.players.find(row=>!reaction.passed.includes(row.id)); assert.ok(next);
|
||||
game=act(game,next.id,{type:'semutaPass',event});
|
||||
}
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
|
||||
return game;
|
||||
}
|
||||
for (const kind of ['bribe','bribeExpression','mixedBribe'] as const)
|
||||
for (const claim of [false,true]) void test(`Basic ${kind} answer retains its payment after Semuta ${claim?'claims':'passes'} Truthtrance`,()=>{
|
||||
const f=fixture(); let game=act(f.game,'guild',{type:'card',card:f.truth});
|
||||
while (game.truthtrance?.stage==='priority') {
|
||||
const next=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(next);
|
||||
game=act(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
const payment={kind:'payment' as const,recipient:'guild',minimum:2};
|
||||
const question=kind==='bribe' ? {kind,target:'emperor',turn:game.turn,recipient:'guild',minimum:2} :
|
||||
kind==='bribeExpression' ? {kind,target:'emperor',turn:game.turn,claim:payment} :
|
||||
{kind,target:'emperor',turn:game.turn,mixed:{kind:'and',terms:[
|
||||
{kind:'fact',fact:{kind:'spice',compare:'eq',value:player(game,'emperor').spice}},
|
||||
{kind:'action',action:{recipient:'guild',minimum:2}},
|
||||
]}};
|
||||
game=act(game,'guild',{type:'truthAsk',question});
|
||||
const view=viewGame(game,'emperor');
|
||||
const answers=kind==='bribe' ? view.truthBribeAnswers :
|
||||
kind==='bribeExpression' ? view.truthBribeExpressionAnswers : view.truthMixedBribeAnswers;
|
||||
assert.ok(answers?.includes('yes'));
|
||||
game=act(game,'emperor',{type:'truthAnswer',answer:'yes'});
|
||||
game=resolveSemuta(game,f.truth,claim);
|
||||
game=advance(game,clean);
|
||||
const guide=viewGame(game,'emperor').bribePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,'guild');
|
||||
game=act(game,'emperor',guide.action);
|
||||
assert.equal(player(game,'guild').bribes,2);
|
||||
if (kind==='bribe') assert.ok(game.bribePromises?.some(row=>row.player==='emperor' && row.fulfilled));
|
||||
else assert.ok(game.bribeExpressionPromises?.some(row=>row.player==='emperor' && row.fulfilled));
|
||||
assert.equal(player(game,'richese').hand.some(card=>card.id===f.truth),claim);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
@@ -33,7 +33,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
|
||||
assert.match(html,/Printed Kull is enabled by default/);
|
||||
assert.match(html,/Combined turn-action Truthtrance questions are unavailable/);
|
||||
assert.match(html,/Unfunded non-CHOAM Karama-dependent bids remain deferred/);
|
||||
assert.match(html,/When Richese is seated, alliance, Revival and bribe commitments are also unavailable/);
|
||||
assert.match(html,/When Richese is seated, alliance and Revival commitments are also unavailable/);
|
||||
const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false});
|
||||
const unselected = viewGame(off,off.host);
|
||||
assert.equal(unselected.lobbyModules?.kullPreview,false);
|
||||
|
||||
Reference in new issue
Block a user