Connect Kull and Semuta with independent cards
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
87d1887139
commit
bc0db31134
11 files changed
+412
-23
No files matched your search
@@ -40,10 +40,10 @@ void test('ongoing games, Advanced rules, absent CHOAM and unsupported deck sele
|
||||
}
|
||||
});
|
||||
|
||||
void test('each optional module blocks automatic entry from both engine and public lobby shapes', () => {
|
||||
void test('other optional modules block automatic entry from both engine and public lobby shapes', () => {
|
||||
for (const option of [
|
||||
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
|
||||
{ lobbyModules: { leaderSkills: true } }, { lobbyModules: { ecazTreachery: true } },
|
||||
{ lobbyOptions: { leaderSkills: true } },
|
||||
{ lobbyModules: { leaderSkills: true } },
|
||||
{ lobbyModules: { nexusCards: true } }, { lobbyModules: { discoveries: true } },
|
||||
{ leaderSkills: {} }, { ecazTreachery: true }, { nexusCards: {} }, { discoveryEnabled: true },
|
||||
{ discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} },
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, 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 { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
||||
import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** A fresh public Basic lobby and actual native setup. For the targeted phase
|
||||
* window, all original hands are returned to the deck, then four existing
|
||||
* physical cards are moved to their owners; the phase and turn are staged. */
|
||||
function fixture(native: 'ecaz' | 'ixians' = 'ecaz') {
|
||||
let game = createGame('KULLINDEPENDENT', newPlayer('choam','CHOAM','choam'), false,
|
||||
['choam','ix','ecaz']);
|
||||
for (const faction of ['richese','atreides',native] as const)
|
||||
joinGame(game,newPlayer(faction,faction,faction));
|
||||
game = act(game,'choam',{type:'moduleOption',module:'ecazTreachery',enabled:true});
|
||||
assert.equal(viewGame(game,'choam').lobbyModules?.kullDefault,true);
|
||||
assert.equal(viewGame(game,'choam').lobbyModules?.kullPreview,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.equal(game.advanced,false);
|
||||
assert.equal(game.kullPreview,true);
|
||||
assert.equal(game.semutaRecruitsPreview,true);
|
||||
assert.equal(game.deck.length,50);
|
||||
assert.equal(game.richeseCache?.length,10);
|
||||
const cards=inventory(game);
|
||||
assert.equal(cards.length,60);
|
||||
assert.ok(cards.includes('ix-kull-wahad'));
|
||||
assert.ok(cards.includes(SEMUTA_DRUG_ID));
|
||||
for (let count=0;game.status==='setup' && count<200;count++) {
|
||||
const step=nextRicheseStrongholdsNativeStep(game); assert.ok(step);
|
||||
game=act(game,step.actor,step.action);
|
||||
}
|
||||
assert.equal(game.status,'playing');
|
||||
for (const seat of game.players) game.deck.push(...seat.hand.splice(0));
|
||||
const karamas=game.deck.filter(card=>card.effect==='karama').map(card=>card.id);
|
||||
assert.ok(karamas.length>=2);
|
||||
const [karama,counter]=karamas;
|
||||
moveSemutaEmperorCard(game,'ix-kull-wahad','choam');
|
||||
moveSemutaEmperorCard(game,karama,'atreides');
|
||||
moveSemutaEmperorCard(game,counter,native);
|
||||
moveSemutaEmperorCard(game,SEMUTA_DRUG_ID,'richese');
|
||||
assert.deepEqual(inventory(game),cards);
|
||||
Object.assign(game,{turn:2,phase:5,active:'atreides',ready:[],response:null,
|
||||
decision:null,phaseOpening:null,choamMarket:null,
|
||||
movementRemaining:game.players.map(seat=>seat.id)});
|
||||
for (const seat of game.players) {seat.shipped=false;seat.moved=0;}
|
||||
return {game,cards,karama,counter,native,kull:'ix-kull-wahad',holder:'richese',actor:'atreides'};
|
||||
}
|
||||
type Fixture=ReturnType<typeof fixture>;
|
||||
const held=(game:Game,seat:string,card:string)=>player(game,seat).hand.some(row=>row.id===card);
|
||||
function offer(f:Fixture) {
|
||||
const game=act(f.game,f.actor,{type:'card',mode:'shipment',card:f.karama,target:f.actor});
|
||||
assert.equal(game.pendingKull?.stage,'offer');
|
||||
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(play=>play.source==='printed' && play.card.id===f.kull && !play.blocked));
|
||||
assert.ok(held(game,f.actor,f.karama));
|
||||
return game;
|
||||
}
|
||||
function declare(f:Fixture,game:Game) {
|
||||
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
|
||||
game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
|
||||
assert.equal(game.pendingKull?.stage,'counter');
|
||||
return game;
|
||||
}
|
||||
function semuta(game:Game,f:Fixture,card:string,discardedBy:string,claim:boolean) {
|
||||
const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.equal(pending.reaction.stage,'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry=>({id:entry.card.id,discardedBy:entry.discardedBy,publicFace:entry.publicFace})),
|
||||
[{id:card,discardedBy,publicFace:true}]);
|
||||
assert.equal(viewGame(game,f.holder).semutaReaction?.canCommit,true);
|
||||
const event=pending.batch.event;
|
||||
if (claim) {
|
||||
game=act(game,f.holder,{type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage==='select')
|
||||
game=act(game,f.holder,{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 seat=game.players.find(row=>!reaction.passed.includes(row.id)); assert.ok(seat);
|
||||
game=act(game,seat.id,{type:'semutaPass',event});
|
||||
}
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const native of ['ecaz','ixians'] as const) void test(`fresh Basic independent Ecaz cards and native ${native} offer default Kull; decline resumes Karama`,()=>{
|
||||
const f=fixture(native), pending=offer(f);
|
||||
const reaction=viewGame(pending,'choam').kullReaction; assert.ok(reaction);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(pending,'choam'); view.players.find(row=>row.id==='choam')!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.equal(action.type,'kullDecision',difficulty);
|
||||
assert.doesNotThrow(()=>act(pending,'choam',action),difficulty);
|
||||
}
|
||||
let game=act(pending,'choam',{type:'kullDecision',event:reaction.event,decline:true});
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.ok(held(game,'choam',f.kull));
|
||||
assert.equal(game.discard.filter(row=>row.id===f.karama).length,1);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} original shipment Karama after Kull declines`,()=>{
|
||||
const f=fixture(), offered=offer(f);
|
||||
const reaction=viewGame(offered,'choam').kullReaction; assert.ok(reaction);
|
||||
const pending=act(offered,'choam',{type:'kullDecision',event:reaction.event,decline:true});
|
||||
assert.equal(pending.karamaShipping,null);
|
||||
let game=semuta(pending,f,f.karama,f.actor,claim);
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
assert.equal(held(game,f.holder,f.karama),claim);
|
||||
assert.equal(held(game,'choam',f.kull),true);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} successful printed Kull`,()=>{
|
||||
const f=fixture(), pending=advance(declare(f,offer(f)),state=>!!state.pendingTreacheryDiscard);
|
||||
assert.ok(held(pending,f.actor,f.karama));
|
||||
assert.equal(pending.karamaShipping,null);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(pending,f.holder); view.players.find(row=>row.id===f.holder)!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.ok(action.type==='semutaPass'||action.type==='semutaCommit',difficulty);
|
||||
assert.doesNotThrow(()=>act(pending,f.holder,action),difficulty);
|
||||
}
|
||||
let game=semuta(pending,f,f.kull,'choam',claim);
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.equal(game.karamaShipping,null);
|
||||
assert.ok(held(game,f.actor,f.karama));
|
||||
assert.ok(viewGame(game,f.actor).karamaBlocked);
|
||||
assert.equal(held(game,f.holder,f.kull),claim);
|
||||
assert.equal(held(game,f.holder,SEMUTA_DRUG_ID),!claim);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} distinct Kull-counter Karama`,()=>{
|
||||
const f=fixture(), declared=declare(f,offer(f));
|
||||
assert.ok(viewGame(declared,f.native).responseControls?.cancelCards.includes(f.counter));
|
||||
const pending=act(declared,f.native,{type:'card',mode:'cancel',card:f.counter});
|
||||
assert.ok(held(pending,f.actor,f.karama));
|
||||
assert.ok(held(pending,'choam',f.kull));
|
||||
let game=semuta(pending,f,f.counter,f.native,claim);
|
||||
game=advance(game,clean);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.equal(game.karamaShipping?.owner,f.actor);
|
||||
assert.ok(held(game,'choam',f.kull));
|
||||
assert.equal(game.discard.filter(row=>row.id===f.karama).length,1);
|
||||
assert.equal(held(game,f.holder,f.counter),claim);
|
||||
assert.equal(held(game,f.holder,SEMUTA_DRUG_ID),!claim);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import { kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
|
||||
|
||||
const lobby: KullDefaultProfile = {
|
||||
status: 'lobby', advanced: false, expansions: ['choam', 'ix', 'ecaz'],
|
||||
players: [{faction:'choam'}, {faction:'ecaz'}, {faction:'richese'}],
|
||||
};
|
||||
|
||||
void test('independent Ecaz cards do not disable the Basic native Kull lobby profile', () => {
|
||||
for (const selection of [
|
||||
{},
|
||||
{lobbyOptions:{ecazTreachery:true}},
|
||||
{lobbyModules:{ecazTreachery:true}},
|
||||
{lobbyOptions:{ecazTreachery:true},lobbyModules:{ecazTreachery:true}},
|
||||
]) assert.deepEqual(kullDefaultProfile({...lobby,...selection}), {eligible:true,reason:null});
|
||||
assert.deepEqual(kullDefaultProfile({...lobby,expansions:['choam','ix'],
|
||||
players:[{faction:'choam'},{faction:'atreides'}],lobbyOptions:{ecazTreachery:true}}),
|
||||
{eligible:true,reason:null}, 'the independent cards do not require the Ecaz faction or deck');
|
||||
});
|
||||
|
||||
void test('the actual public lobby retains the Kull default when independent cards are selected', () => {
|
||||
let game = createGame('KULLINDEPENDENTPROFILE',newPlayer('c','CHOAM','choam'),false,['choam','ix','ecaz']);
|
||||
joinGame(game,newPlayer('e','Ecaz','ecaz'));
|
||||
let view = viewGame(game,'c');
|
||||
assert.equal(view.lobbyModules?.ecazTreachery,false);
|
||||
assert.equal(view.lobbyModules?.kullDefault,true);
|
||||
assert.equal(view.lobbyModules?.kullPreview,true);
|
||||
game = applyAction(game,'c',{type:'moduleOption',module:'ecazTreachery',enabled:true});
|
||||
view = viewGame(game,'c');
|
||||
assert.equal(view.lobbyModules?.ecazTreachery,true);
|
||||
assert.equal(view.lobbyModules?.kullDefault,true);
|
||||
assert.equal(view.lobbyModules?.kullPreview,true);
|
||||
game = applyAction(game,'c',{type:'moduleOption',module:'kullPreview',enabled:false});
|
||||
assert.equal(viewGame(game,'c').lobbyModules?.kullDefault,true);
|
||||
assert.equal(viewGame(game,'c').lobbyModules?.kullPreview,false);
|
||||
game = applyAction(game,'c',{type:'moduleOption',module:'kullPreview',enabled:true});
|
||||
assert.equal(viewGame(game,'c').lobbyModules?.kullPreview,true);
|
||||
assert.equal(viewGame(game,'c').lobbyModules?.ecazTreachery,true);
|
||||
});
|
||||
|
||||
void test('all other modules and an already-initialized card marker remain excluded', () => {
|
||||
const selected = {...lobby,lobbyOptions:{ecazTreachery:true}};
|
||||
for (const other of [
|
||||
{lobbyOptions:{ecazTreachery:true,leaderSkills:true}},
|
||||
{lobbyModules:{ecazTreachery:true,leaderSkills:true}},
|
||||
{lobbyModules:{ecazTreachery:true,nexusCards:true}},
|
||||
{lobbyModules:{ecazTreachery:true,discoveries:true}},
|
||||
{leaderSkills:{}},{ecazTreachery:true},{nexusCards:{}},{discoveryEnabled:true},
|
||||
{discoveries:{}},{discoveryStash:{}},{greatMaker:{}},{homeworlds:{}},
|
||||
{techTokens:{}},{strongholdCards:{}},
|
||||
]) {
|
||||
const result = kullDefaultProfile({...selected,...other});
|
||||
assert.equal(result.eligible,false);
|
||||
assert.ok(result.reason);
|
||||
}
|
||||
assert.equal(kullDefaultProfile({...selected,advanced:true}).eligible,false);
|
||||
assert.equal(kullDefaultProfile({...selected,status:'setup'}).eligible,false);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
const act = (g: Game, id: string, action: Action) => applyAction(g, id, action);
|
||||
type Native = 'tleilaxu' | 'ecaz';
|
||||
|
||||
/** Public selected-family setup; the later forces and held physical cards are
|
||||
* deliberately moved between their original pools. Battle selection and both
|
||||
* preparation controls, Poison and Semuta are actual actions. */
|
||||
function fixture(native: Native) {
|
||||
let game = createGame('SEMUTANATIVERESIDUAL', newPlayer(native, native, native), false,
|
||||
['choam', 'ix', 'ecaz']);
|
||||
for (const faction of ['atreides', 'richese', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = act(game, native, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
||||
game = act(game, native, {type:'start'});
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const original = inventory(game);
|
||||
for (const seat of game.players) {
|
||||
game.deck.push(...seat.hand); seat.hand = [];
|
||||
seat.reserves += seat.tanks + Object.values(seat.forces).reduce((sum, n) => sum + n, 0);
|
||||
seat.tanks = 0; seat.forces = {}; seat.shipped = false; seat.moved = 0;
|
||||
}
|
||||
const take = (id: string) => {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
|
||||
const index = zone.findIndex(card => card.id === id);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw Error(`Missing physical card ${id}`);
|
||||
};
|
||||
const poisonId = game.richeseCache!.find(card => card.effect === 'residualPoison')!.id;
|
||||
player(game, native).hand.push(take(poisonId));
|
||||
player(game, 'guild').hand.push(take(SEMUTA_DRUG_ID));
|
||||
const location = 'red_chasm:7';
|
||||
for (const id of [native, 'atreides']) {
|
||||
player(game, id).reserves -= 3; player(game, id).forces[location] = 3;
|
||||
}
|
||||
Object.assign(game, {turn:2, phase:6, storm:18, active:native,
|
||||
order:[native, 'atreides', 'richese', 'guild'], ready:[], response:null,
|
||||
decision:null, phaseOpening:null, movementRemaining:[]});
|
||||
assert.deepEqual(inventory(game), original);
|
||||
game = act(game, native, {type:'chooseBattle', territory:'red_chasm', target:'atreides'});
|
||||
for (let count = 0; count < 30; count++) {
|
||||
if (game.response) {
|
||||
const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next);
|
||||
game = act(game, next.id, {type:'passResponse'});
|
||||
} else if (game.battle?.preparation) {
|
||||
game = act(game, game.battle.preparation.owner, {type:'declineBattlePower'});
|
||||
} else if (game.decision?.kind === 'fullPlanOffer') {
|
||||
game = act(game, game.decision.player, {type:'decision', decline:true});
|
||||
} else break;
|
||||
}
|
||||
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
|
||||
const quote = viewGame(game, native).residualPoison;
|
||||
assert.ok(quote && !quote.blocked && quote.event && quote.target);
|
||||
return {game, native, holder:'guild', poisonId, event:game.battle.event!, original,
|
||||
poisonAction:{type:'card', card:poisonId, target:quote.target, event:quote.event} as Action};
|
||||
}
|
||||
|
||||
for (const native of ['tleilaxu', 'ecaz'] as const) for (const ready of ['owner', 'opponent'] as const)
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Basic ${native} Residual Poison ${ready}-ready Semuta ${claim ? 'claim' : 'pass'} resumes preparation`, () => {
|
||||
const f = fixture(native), readyPlayer = ready === 'owner' ? native : 'atreides';
|
||||
let game = act(f.game, readyPlayer, {type:'battlePreparationReady', event:f.event});
|
||||
const before = structuredClone(game), deaths = player(game, 'atreides').leaders.filter(leader => leader.dead).map(leader => leader.id);
|
||||
game = act(game, native, f.poisonAction);
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.equal(pending.continuation.kind, 'residualPoisonDiscard');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy, entry.publicFace]), [[f.poisonId, native, true]]);
|
||||
assert.deepEqual(game.battle!.preLeader!.ready, [readyPlayer]);
|
||||
const newlyDead = player(game, 'atreides').leaders.filter(leader => leader.dead && !deaths.includes(leader.id));
|
||||
assert.equal(newlyDead.length, 1);
|
||||
for (const seat of game.players) {
|
||||
const view = viewGame(game, seat.id);
|
||||
assert.deepEqual(view.semutaReaction?.candidates, []);
|
||||
assert.equal(view.semutaReaction?.canCommit, seat.id === f.holder);
|
||||
}
|
||||
if (ready === 'opponent') for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.holder); view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass');
|
||||
assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action));
|
||||
}
|
||||
const event = pending.batch.event;
|
||||
if (claim) {
|
||||
game = act(game, f.holder, {type:'semutaCommit', event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, f.holder, {type:'semutaSelect', event, card:f.poisonId});
|
||||
} else for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(game.battle!.preLeader!.ready, ready === 'owner' ? [] : ['atreides']);
|
||||
assert.deepEqual(game.battle!.plans, before.battle!.plans);
|
||||
assert.equal(player(game, 'atreides').leaders.find(leader => leader.id === newlyDead[0].id)?.dead, true);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === f.poisonId), claim);
|
||||
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
|
||||
assert.equal(game.discard.some(card => card.id === f.poisonId), !claim);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
assert.throws(() => act(game, f.holder, {type:'semutaCommit', event}));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user