Support Truthtrance bribes in Basic Kull preview

This commit is contained in:
Quintine committed 2026-10-27 19:37:38 +11:00
1 parent 92c68ec4f6
commit 21f95bdd5b
15 files changed
+532 -15

No files matched your search

+85
View File
@@ -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);
}
+53
View File
@@ -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);
});
+213
View File
@@ -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);
}
});
+48
View File
@@ -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);
});
+1 -1
View File
@@ -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'));
});