Expose Basic Kull preview in lobby
This commit is contained in:
1 parent
2cd2a60a36
commit
92c68ec4f6
11 files changed
+375
-10
No files matched your search
@@ -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});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user