Expose Basic Kull preview in lobby

This commit is contained in:
Quintine committed 2026-10-27 19:23:23 +11:00
1 parent 2cd2a60a36
commit 92c68ec4f6
11 files changed
+375 -10

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { actKullPurpose, advanceKullPurpose, kullPurposeClean, kullPurposePlayer, type KullPurposeStep } from './fixture-kull-purpose';
export const act = actKullPurpose;
export const advance = advanceKullPurpose;
export const clean = kullPurposeClean;
export const player = kullPurposePlayer;
/** Treachery cards in the physical zones exercised by this native programme. */
export function publicKullInventory(g: Game): string[] {
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index) ?? []), ...(g.richeseCache ?? [])].map(card => card.id).sort();
assert.equal(new Set(cards).size, cards.length, 'Every Treachery card is in one physical zone');
return cards;
}
/** Normal public Basic CHOAM/Emperor/Guild lobby. The host really starts, then
* we select only an unopened original starting deal and unopened Spice prefix.
* Everything after that uses native setup, auction and movement actions. */
export function createPublicChoamKullFixture() {
let game = createGame('PUBLICKULL', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: KullPurposeStep[] = [];
const send = (actor: string, action: Action) => { game = act(game,actor,action,actions); };
const reach = (predicate: (g: Game) => boolean) => { game = advance(game,predicate,actions); };
send(game.host,{type:'moduleOption',module:'kullPreview',enabled:true});
for (const p of game.players) send(p.id,{type:'ready'});
const lobby = structuredClone(game);
send(game.host,{type:'start'});
const setup = structuredClone(game);
assert.equal(game.status,'setup');
assert.equal(game.kullPreview,true,'The public host opt-in enables printed Kull in the supported Basic profile');
assert.ok(game.players.every(p => p.hand.length === 0));
const originalCards = game.deck.map(card => card.id).sort();
const selected: Game['deck'] = [];
for (const name of ['kull','karama','karama']) {
const i = game.deck.findIndex(card => name === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
assert.ok(i >= 0); selected.push(game.deck.splice(i,1)[0]);
}
const unopenedDeal = selected.map((card,index) => ({player:game.players[index].id,card:card.id}));
game.deck.unshift(...selected);
const beforeDeal = structuredClone(game);
reach(g => g.status === 'playing');
const afterSetup = structuredClone(game);
const [kull,karama,counter] = selected.map(card => card.id);
assert.ok(player(game,'choam').hand.some(card => card.id === kull));
assert.ok(player(game,'emperor').hand.some(card => card.id === karama));
assert.ok(player(game,'guild').hand.some(card => card.id === counter));
assert.deepEqual(publicKullInventory(game),originalCards);
const i = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(i >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(i,1)[0]);
reach(g => g.phase === 3 && !!g.auction && g.auction.active === 'emperor' && clean(g));
const auction = structuredClone(game);
assert.deepEqual(publicKullInventory(auction),originalCards);
reach(g => g.phase === 5 && g.active === 'choam' && clean(g));
send('choam',{type:'endMovement'});
reach(g => g.phase === 5 && g.active === 'emperor' && clean(g));
assert.deepEqual(publicKullInventory(game),originalCards);
return {game,lobby,setup,beforeDeal,afterSetup,auction,actions,originalCards,unopenedDeal,
kull,karama,counter,actor:'emperor',reactor:'choam',counterOwner:'guild',
staging:'Selected only original unopened starting cards and a safe unopened Spice prefix; all setup, auction passes and turn changes use real native actions.'};
}
export type PublicChoamKullFixture = ReturnType<typeof createPublicChoamKullFixture>;
export const publicKullAttempt = (f: PublicChoamKullFixture): Action =>
({type:'card',mode:'shipment',card:f.karama,target:f.actor});
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type GameView } from '../game/engine';
import { act, createPublicChoamKullFixture as fixture, publicKullAttempt as attempt } from './fixture-public-choam-kull';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url),'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1],match[1]]));
return {format:'module',source:`export default ${JSON.stringify(classes)}`,shortCircuit:true};
},
});
const { GameTable } = await import('../components/game-table');
const { ChoamKull } = await import('../components/choam-kull');
aliases.deregister();
const table = (game: GameView,busy = false) => renderToStaticMarkup(createElement(GameTable,
{game,busy,send:async () => {},onExit() {}}));
const reaction = (game: GameView,busy = false) => renderToStaticMarkup(createElement(ChoamKull,
{game,busy,act() {}}));
void test('public host lobby shows the explicitly selected Kull preview and its limits', () => {
const f = fixture(), host = viewGame(f.lobby,f.lobby.host), html = table(host);
assert.equal(host.lobbyModules?.kullPreview,true);
assert.match(html,/Kull Wahad · Basic development preview/);
assert.match(html,/does not support structured Truthtrance questions about bids/);
assert.match(html,/defers unfunded non-CHOAM Karama-dependent bids/);
const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false});
const unselected = viewGame(off,off.host);
assert.equal(unselected.lobbyModules?.kullPreview,false);
assert.match(table(unselected),/Kull Wahad · Basic development preview/);
assert.doesNotMatch(table(unselected),/does not support structured Truthtrance questions about bids/);
const nonhost = table(viewGame(f.lobby,f.actor));
assert.match(nonhost,/disabled=""[^>]*type="checkbox"|type="checkbox"[^>]*disabled=""/);
});
void test('the real public table exposes Kull use and decline only to the reactor', () => {
const f = fixture(), offered = act(f.game,f.actor,attempt(f));
const holder = viewGame(offered,f.reactor), other = viewGame(offered,f.actor);
assert.match(reaction(holder),/Use Kull Wahad/);
assert.match(reaction(holder),/Decline Kull/);
assert.match(table(holder),/aria-label="Kull Wahad response"/);
assert.match(table(holder),/Use Kull Wahad/);
assert.match(reaction(holder,true),/disabled=""[^>]*>Use Kull Wahad/);
assert.doesNotMatch(reaction(other),/Use Kull Wahad|Decline Kull|ix-kull-wahad/);
assert.match(reaction(other),/Waiting for/);
});
+152
View File
@@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { bribePromiseModeSupported } from '../game/bribe-promises';
import { act, advance, clean, createPublicChoamKullFixture as fixture, player,
publicKullAttempt as attempt, publicKullInventory as inventory } from './fixture-public-choam-kull';
const held = (g: Game,id: string,card: string) => player(g,id).hand.some(c => c.id === card);
function reject(g: Game,id: string,action: Action) {
const before = structuredClone(g); assert.throws(() => act(g,id,action)); assert.deepEqual(g,before);
}
function createLobby(advanced = false, expansions: string[] = ['choam','ix'], last: 'guild'|'ixians' = 'guild') {
const game = createGame('KULLLOBBY',newPlayer('choam','CHOAM','choam'),advanced,expansions);
joinGame(game,newPlayer('emperor','Emperor','emperor'));
joinGame(game,newPlayer(last,last,last));
return game;
}
function ready(g: Game) {
for (const seat of g.players) g = act(g,seat.id,{type:'ready'});
return g;
}
function declare(g: Game,kull: string) {
const reaction = viewGame(g,'choam').kullReaction; assert.ok(reaction);
return act(g,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:kull});
}
void test('Kull is a host-selected lobby preview and default Basic Truth promises retain their existing scope', () => {
let lobby = createLobby();
assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
assert.equal(bribePromiseModeSupported(lobby),true);
reject(lobby,'emperor',{type:'moduleOption',module:'kullPreview',enabled:true});
reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:'true'});
reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true,other:true});
const baseline = act(ready(lobby),'choam',{type:'start'});
assert.equal(baseline.kullPreview ?? false,false);
assert.equal(bribePromiseModeSupported(baseline),true);
lobby = ready(lobby);
lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
assert.ok(lobby.players.every(seat => !seat.ready));
assert.equal(lobby.lobbyOptions?.kullPreview,true);
lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:false});
assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
const f = fixture();
assert.equal(f.setup.kullPreview,true);
assert.equal(bribePromiseModeSupported(f.setup),false,'The opt-in explicitly retains the existing partial promise boundary');
assert.ok(f.actions.some(row => row.actor === f.lobby.host && row.action.type === 'start'));
});
void test('incompatible opt-in starts reject without modifying their original lobby', () => {
for (const config of [
{advanced:true,expansions:['choam','ix'],last:'guild' as const,module:false},
{advanced:false,expansions:['choam'],last:'guild' as const,module:false},
{advanced:false,expansions:['choam','ix'],last:'ixians' as const,module:false},
{advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true},
]) {
let g = createLobby(config.advanced,config.expansions,config.last);
g = act(g,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
if (config.module) g = act(g,'choam',{type:'moduleOption',module:'leaderSkills',enabled:true});
g = ready(g);
reject(g,'choam',config.advanced ? {type:'start',advancedPreview:true} : {type:'start'});
}
});
void test('public Kull offer, decline, successful use, and distinct counter retain native custody and income', () => {
const f = fixture(), original = structuredClone(f.game), sourceCards = inventory(original);
assert.equal(original.kullPreview,true); assert.deepEqual(sourceCards,f.originalCards);
const wallet = original.players.map(p => p.spice), forces = original.players.map(p => ({forces:p.forces,reserves:p.reserves,tanks:p.tanks}));
const offered = act(original,f.actor,attempt(f));
assert.equal(offered.pendingKull?.stage,'offer');
assert.equal(offered.karamaShipping,null);
assert.ok(held(offered,f.actor,f.karama));
assert.deepEqual(offered.players.map(p => p.spice),wallet);
assert.deepEqual(offered.players.map(p => ({forces:p.forces,reserves:p.reserves,tanks:p.tanks})),forces);
for (const outsider of [f.actor,f.counterOwner]) {
const reaction = viewGame(offered,outsider).kullReaction; assert.ok(reaction);
assert.equal(reaction.canDecline,false); assert.deepEqual(reaction.plays,[]);
}
const reaction = viewGame(offered,f.reactor).kullReaction; assert.ok(reaction);
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull && !play.blocked));
reject(offered,f.counterOwner,{type:'kullDecision',event:reaction.event,decline:true});
reject(offered,f.reactor,{type:'kullDecision',event:'stale',decline:true});
const declined = act(offered,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
assert.equal(declined.pendingKull,null);
assert.equal(declined.karamaShipping?.owner,f.actor);
assert.equal(declined.discard.filter(card => card.id === f.karama).length,1);
assert.ok(held(declined,f.reactor,f.kull));
assert.deepEqual(inventory(declined),sourceCards);
const declared = declare(offered,f.kull);
const successful = advance(declared,clean);
assert.equal(successful.karamaShipping,null);
assert.ok(held(successful,f.actor,f.karama));
assert.equal(successful.discard.filter(card => card.id === f.kull).length,1);
assert.deepEqual(successful.players.map(p => p.spice),wallet);
assert.deepEqual(inventory(successful),sourceCards);
reject(successful,f.actor,attempt(f));
const counter = viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? [];
assert.ok(counter.includes(f.counter),'The distinct original Guild Karama is a legal counter');
const interrupted = act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
const resumed = advance(interrupted,clean);
assert.equal(resumed.pendingKull,null);
assert.equal(resumed.karamaShipping?.owner,f.actor);
assert.ok(held(resumed,f.reactor,f.kull));
assert.equal(resumed.discard.filter(card => card.id === f.karama).length,1);
assert.equal(resumed.discard.filter(card => card.id === f.counter).length,1);
assert.deepEqual(inventory(resumed),sourceCards);
});
void test('neutral public offer and deferred overbid do not depend on holding Kull', () => {
const f = fixture(), absent = structuredClone(f.game), owner = player(absent,f.reactor);
// Conserved controlled supplement: swap the original held Kull and a physical
// undealt deck card; this is not an earned trade or a new card.
const heldIndex = owner.hand.findIndex(card => card.id === f.kull); assert.ok(heldIndex >= 0);
assert.ok(absent.deck.length); [owner.hand[heldIndex],absent.deck[0]] = [absent.deck[0],owner.hand[heldIndex]];
assert.deepEqual(inventory(absent),f.originalCards);
const withCard = act(f.game,f.actor,attempt(f)), withoutCard = act(absent,f.actor,attempt(f));
assert.deepEqual(viewGame(withCard,f.actor).kullReaction,viewGame(withoutCard,f.actor).kullReaction);
const auctionWithout = structuredClone(f.auction), auctionOwner = player(auctionWithout,f.reactor);
const index = auctionOwner.hand.findIndex(card => card.id === f.kull); assert.ok(index >= 0);
assert.ok(auctionWithout.deck.length); [auctionOwner.hand[index],auctionWithout.deck[0]] = [auctionWithout.deck[0],auctionOwner.hand[index]];
for (const g of [f.auction,auctionWithout]) {
const amount = player(g,f.actor).spice + 1;
reject(g,f.actor,{type:'bid',amount});
assert.notEqual(g.auction?.bidder,f.actor);
assert.ok(held(g,f.actor,f.karama));
}
});
void test('all four profiles use only the projected physical Kull cost', () => {
const f = fixture(), offered = act(f.game,f.actor,attempt(f));
for (const difficulty of DIFFICULTIES) {
const v = viewGame(offered,f.reactor); v.players.find(p => p.id === f.reactor)!.bot = difficulty;
const action = botActions(v)[0]; assert.ok(action,difficulty);
assert.deepEqual(action,{type:'kullDecision',event:v.kullReaction!.event,source:'printed',card:f.kull});
const result = act(offered,f.reactor,action); assert.equal(result.pendingKull?.stage,'counter');
assert.deepEqual(inventory(result),f.originalCards);
}
});
void test('a legacy unmarked Basic game retains its original Karama path', () => {
const f = fixture(), legacy = structuredClone(f.game);
// This controlled legacy-shape supplement removes the opt-in stamp only;
// the original cards, turn, board and wallets remain from native play.
delete legacy.kullPreview;
const completed = act(legacy,f.actor,attempt(f));
assert.equal(completed.pendingKull ?? null,null);
assert.equal(completed.karamaShipping?.owner,f.actor);
assert.equal(completed.discard.filter(card => card.id === f.karama).length,1);
assert.ok(held(completed,f.reactor,f.kull));
assert.deepEqual(inventory(completed),f.originalCards);
});