Enable Basic Kull preview with native Ix factions
This commit is contained in:
1 parent
cd1f726593
commit
69a9330810
12 files changed
+312
-16
No files matched your search
@@ -0,0 +1,78 @@
|
||||
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';
|
||||
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 IxNativeRoster = 'ixians' | 'tleilaxu' | 'both';
|
||||
|
||||
/** Host-created Basic Kull lobby with genuine native Ix setup. Only original
|
||||
* unopened Treachery and Spice positions are selected; every later payment,
|
||||
* decision, force, Face Dancer and movement transition comes from native play.
|
||||
* The scalar shuffle preserves the selected Ixian starting-pool deal order. */
|
||||
export function createKullNativeIxFixture(roster: IxNativeRoster) {
|
||||
const actor = roster === 'ixians' ? 'ixians' : 'tleilaxu';
|
||||
let game = createGame('NATIVEKULL', newPlayer('choam','CHOAM','choam'), false, ['choam','ix']);
|
||||
const natives = roster === 'both' ? ['ixians','tleilaxu'] as const : [roster] as const;
|
||||
for (const faction of natives) joinGame(game, newPlayer(faction,faction,faction));
|
||||
for (const faction of ['emperor','guild'] as const) joinGame(game, newPlayer(faction,faction,faction));
|
||||
const actions: KullPurposeStep[] = [];
|
||||
const send = (seat: string, action: Action) => { game = act(game,seat,action,actions); };
|
||||
const reach = (until: (state: 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.status,'setup');
|
||||
assert.equal(game.kullPreview,true);
|
||||
assert.ok(game.players.every(seat => seat.hand.length === 0));
|
||||
const originalCards = game.deck.map(card=>card.id).sort();
|
||||
const selected: Game['deck'] = [];
|
||||
// In the Ixian starting pool, the first offered original card is selected,
|
||||
// then the remaining pool is dealt in non-Ixian seat order. The 'both'
|
||||
// roster therefore gives Ixians a harmless original card before the other
|
||||
// native faction receives its own Karama.
|
||||
const kinds = roster === 'ixians' ? ['karama','kull','karama','truth'] :
|
||||
roster === 'tleilaxu' ? ['kull','karama','karama','truth'] :
|
||||
['filler','kull','karama','karama','truth'];
|
||||
for (const kind of kinds) {
|
||||
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,`Original ${kind} is available before deal`);
|
||||
selected.push(game.deck.splice(index,1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
const beforeDeal = structuredClone(game);
|
||||
reach(state=>state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const kull = 'ix-kull-wahad';
|
||||
const karama = player(game,actor).hand.find(card=>card.effect === 'karama')?.id;
|
||||
const counter = player(game,'emperor').hand.find(card=>card.effect === 'karama')?.id;
|
||||
const truth = player(game,'guild').hand.find(card=>card.effect === 'truthtrance')?.id;
|
||||
assert.ok(karama && counter && truth);
|
||||
assert.notEqual(karama,counter);
|
||||
assert.ok(player(game,'choam').hand.some(card=>card.id === kull));
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
if (roster !== 'tleilaxu') assert.ok(game.mobileStronghold);
|
||||
if (roster !== 'ixians') assert.equal(player(game,'tleilaxu').faceDancers?.length,3);
|
||||
const spice = game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery);
|
||||
assert.ok(spice >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(spice,1)[0]);
|
||||
reach(state=>state.phase <= 1 && clean(state));
|
||||
const early = structuredClone(game);
|
||||
reach(state=>state.phase === 5 && state.active === actor && clean(state));
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
return { game, early, lobby, setup, beforeDeal, afterSetup, actions, originalCards,
|
||||
actor, reactor:'choam', counterOwner:'emperor', asker:'guild', recipient:'emperor',
|
||||
kull, karama, counter, truth };
|
||||
}
|
||||
export type KullNativeIxFixture = ReturnType<typeof createKullNativeIxFixture>;
|
||||
export const attempt = (f: KullNativeIxFixture): Action =>
|
||||
({type:'card',mode:'shipment',card:f.karama,target:f.actor});
|
||||
@@ -12,7 +12,7 @@ const basic = {
|
||||
players: [{faction:'choam'}, {faction:'emperor'}, {faction:'guild'}],
|
||||
};
|
||||
|
||||
void test('Basic printed Kull supports the shared bribe family only in its original public envelope', () => {
|
||||
void test('Basic printed Kull supports the shared bribe family in its declared native envelope', () => {
|
||||
for (const quote of [bribePromiseModeSupported, bribeExpressionPromiseModeSupported]) {
|
||||
assert.equal(quote({...basic,kullPreview:true}),true);
|
||||
assert.equal(quote({...basic,expansions:['ix','choam'],kullPreview:true}),true);
|
||||
@@ -21,7 +21,9 @@ void test('Basic printed Kull supports the shared bribe family only in its origi
|
||||
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:'ixians'}],kullPreview:true,mobileStronghold:{}}),true);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'tleilaxu'}],kullPreview:true}),true);
|
||||
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'richese'}],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',
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, initializeKullGameForAudit, joinGame, newPlayer, viewGame, 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, player, inventory, attempt, createKullNativeIxFixture,
|
||||
type KullNativeIxFixture, type IxNativeRoster } from './fixture-kull-native-ix';
|
||||
|
||||
const held = (game: Game, seat: string, card: string) => player(game,seat).hand.some(row=>row.id === card);
|
||||
function question(f: KullNativeIxFixture, game: Game): Game {
|
||||
game = act(game,f.asker,{type:'card',card:f.truth});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
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');
|
||||
game = act(game,f.asker,{type:'truthAsk',question:{kind:'bribe',target:f.actor,
|
||||
turn:game.turn,recipient:f.recipient,minimum:1}});
|
||||
assert.ok(viewGame(game,f.actor).truthBribeAnswers?.includes('yes'));
|
||||
return act(game,f.actor,{type:'truthAnswer',answer:'yes'});
|
||||
}
|
||||
function offer(f: KullNativeIxFixture, 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));
|
||||
assert.ok(held(game,f.actor,f.karama));
|
||||
return {game,reaction};
|
||||
}
|
||||
function declare(f: KullNativeIxFixture, game: 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('the native Ix faction extension does not open the Advanced audit initializer', () => {
|
||||
for (const faction of ['ixians', 'tleilaxu'] as const) {
|
||||
const game = createGame('ADVANCEDKULL', newPlayer('choam','CHOAM','choam'), true, ['choam','ix']);
|
||||
joinGame(game, newPlayer(faction,faction,faction));
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => initializeKullGameForAudit(game), /Kull preview/);
|
||||
assert.deepEqual(game,before);
|
||||
}
|
||||
});
|
||||
|
||||
for (const roster of ['ixians','tleilaxu','both'] as const satisfies readonly IxNativeRoster[]) {
|
||||
void test(`public Basic ${roster} native setup and Kull retain original Karama, counter and bribe`,()=>{
|
||||
const f = createKullNativeIxFixture(roster);
|
||||
assert.ok(f.actions.some(row=>row.actor === f.lobby.host && row.action.type === 'start'));
|
||||
assert.equal(bribePromiseModeSupported(f.game),true);
|
||||
const bound = question(f,f.game);
|
||||
const savedPromise = structuredClone(bound.bribePromises);
|
||||
const {game:pending,reaction} = offer(f,bound);
|
||||
assert.equal(viewGame(pending,f.actor).bribePromiseGuide,null);
|
||||
assert.deepEqual(pending.bribePromises,savedPromise);
|
||||
|
||||
const declined = act(pending,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
|
||||
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(declined.bribePromises,savedPromise);
|
||||
assert.deepEqual(inventory(declined),f.originalCards);
|
||||
|
||||
const declared = declare(f,pending);
|
||||
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.bribePromises,savedPromise);
|
||||
const paid = act(successful,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
assert.equal(player(paid,f.recipient).bribes,1);
|
||||
assert.ok(paid.bribePromises?.at(-1)?.fulfilled);
|
||||
assert.deepEqual(inventory(paid),f.originalCards);
|
||||
|
||||
const counter = viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? [];
|
||||
assert.ok(counter.includes(f.counter),'The Emperor holds a distinct real Karama counter');
|
||||
const interrupted = act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
|
||||
const resumed = advance(interrupted,clean);
|
||||
assert.equal(resumed.karamaShipping?.owner,f.actor);
|
||||
assert.ok(held(resumed,f.reactor,f.kull));
|
||||
assert.equal(resumed.discard.filter(card=>card.id === f.counter).length,1);
|
||||
assert.equal(resumed.discard.filter(card=>card.id === f.karama).length,1);
|
||||
assert.deepEqual(resumed.bribePromises,savedPromise);
|
||||
const paidAfterCounter = act(resumed,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
assert.ok(paidAfterCounter.bribePromises?.at(-1)?.fulfilled);
|
||||
assert.deepEqual(inventory(paidAfterCounter),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
void test('all four minimal bot profiles can choose the projected Kull in a native Ixian game',()=>{
|
||||
const f = createKullNativeIxFixture('ixians'), pending = offer(f,f.game).game;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending,f.reactor);
|
||||
view.players.find(row=>row.id === f.reactor)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.deepEqual(action,{type:'kullDecision',event:view.kullReaction!.event,source:'printed',card:f.kull});
|
||||
const declared = act(pending,f.reactor,action);
|
||||
assert.equal(declared.pendingKull?.stage,'counter');
|
||||
assert.deepEqual(inventory(declared),f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
for (const roster of ['ixians','tleilaxu','both'] as const satisfies readonly IxNativeRoster[]) {
|
||||
void test(`a ${roster} Charity-funded bribe Yes survives Kull decline, block and a distinct counter`,()=>{
|
||||
const f = createKullNativeIxFixture(roster);
|
||||
const purse = player(f.early,f.actor).spice;
|
||||
assert.ok(purse > 0);
|
||||
let source = act(f.early,f.actor,{type:'bribe',target:f.recipient,amount:purse});
|
||||
assert.equal(player(source,f.actor).spice,0);
|
||||
source = question(f,source);
|
||||
const income = advance(source,state=>state.phase === 2 && state.response?.kind === 'choamCharity');
|
||||
assert.equal(income.choamCharity?.turn === income.turn,false);
|
||||
const promise = 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,promise);
|
||||
assert.equal(viewGame(pending,f.actor).bribePromiseGuide,null);
|
||||
const reaction = viewGame(pending,f.reactor).kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(row=>row.card.id === f.kull && !row.blocked));
|
||||
const declined = advance(act(pending,f.reactor,{type:'kullDecision',event:reaction.event,decline:true}),clean);
|
||||
assert.equal(declined.choamCharity?.canceled,true);
|
||||
const declared = declare(f,pending);
|
||||
const successful = advance(declared,clean);
|
||||
assert.equal(successful.choamCharity?.canceled,false);
|
||||
const choices = viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? [];
|
||||
assert.ok(choices.includes(f.counter));
|
||||
const interrupted = act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
|
||||
const countered = advance(interrupted,clean);
|
||||
assert.equal(countered.choamCharity?.canceled,true);
|
||||
for (const outcome of [declined,successful,countered]) {
|
||||
assert.deepEqual(outcome.bribePromises,promise);
|
||||
const guide = viewGame(outcome,f.actor).bribePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'charity');
|
||||
const charity = act(outcome,f.actor,guide.action);
|
||||
assert.equal(player(charity,f.actor).charityTurn,charity.turn);
|
||||
const paid = act(charity,f.actor,{type:'bribe',target:f.recipient,amount:1});
|
||||
assert.equal(player(paid,f.recipient).bribes,purse+1);
|
||||
assert.ok(paid.bribePromises?.at(-1)?.fulfilled);
|
||||
assert.deepEqual(inventory(paid),f.originalCards);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const held = (g: Game,id: string,card: string) => player(g,id).hand.some(c => c.
|
||||
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') {
|
||||
function createLobby(advanced = false, expansions: string[] = ['choam','ix'], last: 'guild'|'richese' = 'guild') {
|
||||
const game = createGame('KULLLOBBY',newPlayer('choam','CHOAM','choam'),advanced,expansions);
|
||||
joinGame(game,newPlayer('emperor','Emperor','emperor'));
|
||||
joinGame(game,newPlayer(last,last,last));
|
||||
@@ -52,7 +52,7 @@ void test('incompatible opt-in starts reject without modifying their original lo
|
||||
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:'richese' as const,module:false},
|
||||
{advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true},
|
||||
]) {
|
||||
let g = createLobby(config.advanced,config.expansions,config.last);
|
||||
|
||||
Reference in new issue
Block a user