Enable Basic Nexus Kull and native reactions
Verify and publish checkpoint container / container (push) Successful in 11m12s
Verify and publish checkpoint container / container (push) Successful in 11m12s
This commit is contained in:
1 parent
5b66628399
commit
95ebd7f976
15 files changed
+388
-21
No files matched your search
@@ -160,6 +160,12 @@ export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | '
|
||||
if (game.decision?.kind === 'guildShipment')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
|
||||
game = allowNativeParent(game);
|
||||
while (game.pendingGuildBetrayal) {
|
||||
const seat = game.players.find(p => viewGame(game, p.id).guildBetrayalReaction?.canPass);
|
||||
if (!seat) throw new Error('The original Guild Betrayal requires an explicit acknowledgement.');
|
||||
const event = viewGame(game, seat.id).guildBetrayalReaction!.event;
|
||||
game = applyAction(game, seat.id, { type: 'guildBetrayalPass', event });
|
||||
}
|
||||
game = applyAction(game, f.actor, { type: 'move', from: 'arrakeen:10', territory: 'hagga_basin', sector: 12, amount: 1 });
|
||||
game = applyAction(game, f.actor, { type: 'endMovement', nexus: viewGame(game, f.actor).nexusGuildCunning!.offer!.event });
|
||||
}
|
||||
|
||||
@@ -45,13 +45,15 @@ void test('other optional modules block automatic entry from both engine and pub
|
||||
for (const option of [
|
||||
{ lobbyOptions: { leaderSkills: true } },
|
||||
{ lobbyModules: { leaderSkills: true } },
|
||||
{ lobbyModules: { nexusCards: true } }, { lobbyModules: { discoveries: true } },
|
||||
{ leaderSkills: {} }, { ecazTreachery: true }, { nexusCards: {} }, { discoveryEnabled: true },
|
||||
{ lobbyModules: { discoveries: true } },
|
||||
{ leaderSkills: {} }, { ecazTreachery: true }, { discoveryEnabled: true },
|
||||
{ discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} },
|
||||
{ homeworlds: {} }, { techTokens: {} }, { strongholdCards: {} },
|
||||
]) {
|
||||
assert.equal(kullDefaultEligible({ ...lobby, ...option }), false);
|
||||
}
|
||||
assert.equal(kullDefaultEligible({ ...lobby, nexusCards: {} }), true);
|
||||
assert.equal(kullDefaultEligible({ ...lobby, lobbyModules: { nexusCards: true } }), true);
|
||||
assert.equal(kullDefaultEligible({ ...lobby,
|
||||
lobbyModules: { leaderSkills: false, ecazTreachery: false, nexusCards: false, discoveries: false },
|
||||
lobbyOptions: { leaderSkills: false, ecazTreachery: false } }), true);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { kullDefaultEligible, kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
|
||||
|
||||
const lobby: KullDefaultProfile = {
|
||||
status: 'lobby', advanced: false, expansions: ['choam', 'ix'],
|
||||
players: [{ faction: 'choam' }, { faction: 'atreides' }, { faction: 'guild' }],
|
||||
nexusCards: { cards: null, phase: null },
|
||||
};
|
||||
|
||||
void test('Basic CHOAM and classic Nexus lobby receives the same public Kull default quote', () => {
|
||||
assert.deepEqual(kullDefaultProfile(lobby), { eligible: true, reason: null });
|
||||
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }] }), true);
|
||||
// GameView carries Nexus selection as a public boolean instead of Game state.
|
||||
assert.equal(kullDefaultEligible({ ...lobby, nexusCards: undefined, lobbyModules: { nexusCards: true } }), true);
|
||||
// The host's explicit opt-out belongs to the caller, not the eligibility quote.
|
||||
const optedOut = { leaderSkills: false, kullOptOut: true };
|
||||
assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: optedOut }), true);
|
||||
});
|
||||
|
||||
void test('Nexus Kull default retains its native roster and module boundaries', () => {
|
||||
const unsupported: Partial<KullDefaultProfile>[] = [
|
||||
{ advanced: true }, { status: 'playing' },
|
||||
{ expansions: ['choam'] }, { expansions: ['choam', 'ix', 'ecaz'] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'richese' }] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'ixians' }] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'atreides' }, { faction: 'atreides' }] },
|
||||
{ players: [{ faction: 'atreides' }, { faction: 'guild' }] },
|
||||
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
|
||||
{ lobbyModules: { nexusCards: true, ecazTreachery: true } },
|
||||
{ lobbyModules: { nexusCards: true, discoveries: true } },
|
||||
{ leaderSkills: {} }, { ecazTreachery: true }, { discoveryEnabled: true },
|
||||
{ discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} }, { homeworlds: {} },
|
||||
{ techTokens: {} }, { strongholdCards: {} }, { sandtrout: {} },
|
||||
];
|
||||
for (const variant of unsupported) {
|
||||
const result = kullDefaultProfile({ ...lobby, ...variant });
|
||||
assert.equal(result.eligible, false, JSON.stringify(variant));
|
||||
assert.ok(result.reason, 'Unavailable lobbies need a readable reason');
|
||||
}
|
||||
});
|
||||
|
||||
void test('Nexus Kull eligibility depends on public roster and options, never hidden cards or spice', () => {
|
||||
const privateSeat = { faction: 'choam', get hand() { throw Error('private hand read'); },
|
||||
get spice() { throw Error('private spice read'); } };
|
||||
assert.equal(kullDefaultEligible({ ...lobby, players: [privateSeat, { faction: 'fremen' }] }), true);
|
||||
});
|
||||
@@ -45,8 +45,8 @@ void test('fresh admission accepts exactly native classic CHOAM, physical CHOAM/
|
||||
assert.equal(initialized.nexusKullPreview, true);
|
||||
assert.ok(initialized.deck.some(card => card.id === 'ix-kull-wahad'));
|
||||
assert.deepEqual(initialized.spiceDeck.map(card => JSON.stringify(card)).sort(),
|
||||
spiceDeck().map(card => JSON.stringify(card)).sort());
|
||||
assert.equal(initialized.spiceDeck.some(card => 'sandtrout' in card), false);
|
||||
spiceDeck(true).map(card => JSON.stringify(card)).sort());
|
||||
assert.equal(initialized.spiceDeck.filter(card => 'sandtrout' in card).length, 1);
|
||||
for (const corrupt of [
|
||||
(g: Game) => { g.expansions = ['choam']; },
|
||||
(g: Game) => { g.nexusCards = undefined; },
|
||||
@@ -312,7 +312,9 @@ void test('independent canonical descriptors and unique live custody govern fuel
|
||||
]) {
|
||||
const offer = reload(f.game); change(offer);
|
||||
const card = offer.players[0].hand[0].id;
|
||||
assert.equal(viewGame(offer, f.choam).kullReaction!.plays.some(play => play.card.id === card), false);
|
||||
// The now-enabled native Ixian replacement profile rejects the corrupt
|
||||
// physical inventory before any Kull view is projected.
|
||||
assert.throws(() => viewGame(offer, f.choam));
|
||||
reject(offer, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card });
|
||||
const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel });
|
||||
const corrupt = reload(declared); change(corrupt);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game} from '../game/engine';
|
||||
import {TERRITORIES} from '../game/board';
|
||||
import {traitorDeck} from '../game/traitors';
|
||||
import {validateNexusTraitorSnapshot} from '../game/nexus-traitor-exchange';
|
||||
import {nextHarkonnenBetrayalNativeStep, settleHarkonnenBetrayalNativeCounters,
|
||||
} from './fixture-nexus-harkonnen-betrayal';
|
||||
import {nextRicheseStrongholdsNativeStep} from './fixture-richese-strongholds';
|
||||
|
||||
const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action);
|
||||
const player = (game:Game, id:string) => {const seat=game.players.find(p=>p.id===id);assert.ok(seat);return seat;};
|
||||
const step = (game:Game) => {
|
||||
if(game.pendingGuildBetrayal) {const frame=game.pendingGuildBetrayal;const actor=frame.required.find(id=>!frame.passed.includes(id));assert.ok(actor);return act(game,actor,{type:'guildBetrayalPass',event:frame.invoice.event});}
|
||||
const next=(!game.response && !game.phaseOpening && (game.decision?.kind.startsWith('richese') || game.richeseAuction && !game.richeseAuction.outcome)) ? nextRicheseStrongholdsNativeStep(game)! : nextHarkonnenBetrayalNativeStep(game); try {return act(game,next.actor,next.action);} catch (error) {throw Error(`Step ${game.turn}/${game.phase}/${game.decision?.kind} ${JSON.stringify(next)}: ${String(error)}`);}};
|
||||
function advance(game:Game, until:(game:Game)=>boolean, limit=1800):Game {
|
||||
for(let i=0;i<limit;i++) {if(until(game)) return game; assert.notEqual(game.status,'finished'); game=step(game);}
|
||||
throw Error(`Native expanded Nexus journey stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
function inventory(game:Game):string[] {
|
||||
return [...game.deck,...game.discard,...(game.richeseCache??[]),...(game.richeseRemoved??[]),
|
||||
...(game.auction?.cards.slice(game.auction.index)??[]),...game.players.flatMap(p=>p.hand)].map(card=>card.id).sort();
|
||||
}
|
||||
function traitors(game:Game):void {
|
||||
validateNexusTraitorSnapshot({reserve:game.traitorReserve!,players:game.players.map(({id,faction,traitors,faceDancers})=>({id,faction,traitors,...(faceDancers?{faceDancers}: {})}))},traitorDeck(game.players,game.expansions.includes('ix')));
|
||||
}
|
||||
/** Native public selected-deck start, original setup and second-turn Nexus
|
||||
* draw. Conserved unopened deck order, one held traitor and two physical
|
||||
* reserve positions are staged; battle selection, plans and call are real. */
|
||||
function fixture(mixed:boolean) {
|
||||
const faction = mixed ? 'ixians' : 'choam';
|
||||
let game=createGame('HARKSEATED',newPlayer('i',faction,faction),false,mixed ? ['ix','choam'] : ['choam']);
|
||||
for(const [id,faction] of [['h','harkonnen'],['r','richese'],['g','guild']] as const)
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
game=act(game,'i',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
for(const seat of game.players) game=act(game,seat.id,{type:'ready'});
|
||||
game=act(game,'i',{type:'start'});
|
||||
assert.equal(game.nexusHarkonnenBetrayalPreview,true);
|
||||
if (!mixed) assert.equal(game.semutaPreview,true, 'Fresh CHOAM/Richese start retains Semuta');
|
||||
if (mixed) assert.equal(game.nexusIxianBetrayalPreview,true);
|
||||
assert.equal(game.guildBetrayalPreview,true);
|
||||
game=advance(game,state=>state.status==='playing');
|
||||
const physical=inventory(game);traitors(game);
|
||||
assert.ok(game.richeseCache?.length,'Native Richese retains its separate cache');
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card=>card===null));
|
||||
const identity=player(game,'i').leaders.find(leader=>!leader.dead)!.id;
|
||||
const h=player(game,'h');
|
||||
if(!h.traitors.includes(identity)) {
|
||||
const replacement=h.traitors[0];
|
||||
const owner=game.players.find(seat=>seat.id!=='h'&&seat.traitors.includes(identity));
|
||||
if(owner) owner.traitors[owner.traitors.indexOf(identity)]=replacement;
|
||||
else {
|
||||
const dancer=game.players.flatMap(seat=>seat.faceDancers??[]).find(card=>card.leader===identity);
|
||||
if(dancer) dancer.leader=replacement;
|
||||
else {const index=game.traitorReserve!.indexOf(identity);assert.ok(index>=0);game.traitorReserve![index]=replacement;}
|
||||
}
|
||||
h.traitors[0]=identity;
|
||||
}
|
||||
traitors(game);
|
||||
const nexus=game.nexusCards!.cards!;
|
||||
const nx=nexus.deck.indexOf('harkonnen');assert.ok(nx>=0);
|
||||
nexus.deck.unshift(nexus.deck.splice(nx,1)[0]);
|
||||
for(let pos=0;pos<2;pos++) {
|
||||
const index=game.spiceDeck.findIndex((card,i)=>i>=pos&&'territory'in card);assert.ok(index>=pos);
|
||||
game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]);
|
||||
}
|
||||
game=advance(game,state=>state.turn===2&&state.phase===1&&!state.response&&!state.decision);
|
||||
const worm=game.spiceDeck.findIndex(card=>'worm'in card&&!card.greatMaker&&!card.suppressed);assert.ok(worm>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
|
||||
game=advance(game,state=>state.nexus&&!state.spiceWindow&&!state.spiceResolution&&!state.response&&!state.decision);
|
||||
game=act(game,'h',{type:'alliance',target:'r'});game=act(game,'r',{type:'alliance',target:'h'});
|
||||
game=advance(game,state=>state.nexusCards?.phase?.stage==='drawing');
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('i'));
|
||||
game=act(game,'i',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
assert.equal(game.nexusCards!.cards!.hands.i,'harkonnen');
|
||||
game=advance(game,state=>state.phase===5&&!state.response&&!state.decision&&!state.pendingGuildBetrayal);
|
||||
const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&
|
||||
game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))));assert.ok(territory);
|
||||
for(const id of ['h','i']) {const seat=player(game,id);assert.ok(seat.reserves>0);seat.reserves--;seat.forces[`${territory.id}:${territory.sectors[0]}`]=1;}
|
||||
game=advance(game,state=>state.phase===6&&!!state.active&&!state.phaseOpening&&!state.response&&!state.decision);
|
||||
const chooser=game.active!;assert.ok(['h','i'].includes(chooser), `chooser ${chooser}; battle ${JSON.stringify(game.battle)}; decision ${JSON.stringify(game.decision)}; pending ${JSON.stringify(game.pendingGuildBetrayal)}; armies ${JSON.stringify(game.players.map(p=>[p.id,p.forces]))}`);
|
||||
game=act(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser==='h'?'i':'h'});
|
||||
game=advance(game,state=>!!state.battle&&!state.response&&!state.decision&&!state.battle.preparation&&
|
||||
(!state.battle.preLeader||state.battle.preLeader.closed));
|
||||
for(const id of [game.battle!.attacker,game.battle!.defender]) {
|
||||
const leader=id==='i'?identity:player(game,id).leaders.find(l=>!l.dead&&!l.usedAt)!.id;
|
||||
game=act(game,id,{type:'battlePlan',dial:0,leader});
|
||||
}
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
game=advance(game,state=>!state.response&&!state.decision);
|
||||
game=act(game,'h',{type:'traitorCall',call:true});
|
||||
game=settleHarkonnenBetrayalNativeCounters(game);
|
||||
const reaction=viewGame(game,'i').nexusHarkonnenBetrayalReaction;
|
||||
assert.ok(reaction,'The actual expansion-roster traitor call opens native Betrayal');
|
||||
assert.equal(reaction.canUse,true);assert.equal(reaction.identity,identity);
|
||||
assert.deepEqual(inventory(game),physical);traitors(game);
|
||||
return {game,event:reaction.event,identity,physical};
|
||||
}
|
||||
|
||||
for(const mixed of [false,true]) for(const use of [false,true]) void test(`Basic public ${mixed?'Ix/CHOAM':'CHOAM/Semuta'} seated-family Harkonnen Nexus Betrayal ${use?'Use':'Pass'}`,()=>{
|
||||
const f=fixture(mixed);let game=f.game;
|
||||
if(use) game=act(game,'i',{type:'nexusHarkonnenBetrayalUse',event:f.event});
|
||||
else {
|
||||
for(const id of game.pendingNexusHarkonnenBetrayal!.required)
|
||||
game=act(game,id,{type:'nexusHarkonnenBetrayalPass',event:f.event});
|
||||
}
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal,null);
|
||||
assert.equal(game.nexusCards!.cards!.hands.i,use?null:'harkonnen');
|
||||
assert.equal(game.battle!.traitorCalls.h,!use);
|
||||
assert.equal(player(game,'h').traitors.includes(f.identity),!use);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null);
|
||||
assert.deepEqual(inventory(game),f.physical);traitors(game);
|
||||
game=advance(game,state=>state.phase===8);
|
||||
if (mixed) {
|
||||
assert.equal(game.phaseOpening?.initialize,true, 'Ix opening precedes actual Mentat initialization');
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null);
|
||||
}
|
||||
game=advance(game,state=>state.phase===8&&!state.pendingNexusHarkonnenReplacement&&!state.phaseOpening);
|
||||
assert.equal(game.lastBattleContext!.result,use?'normal':'traitor');
|
||||
assert.equal(game.nexusHarkonnenReplacementHistory!.length,use?1:0);
|
||||
assert.equal(player(game,'h').traitors.length,4);
|
||||
assert.deepEqual(inventory(game),f.physical);traitors(game);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { takeKullCard } from './fixture-choam-kull';
|
||||
import { advanceSemutaNexusTrade, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
|
||||
import { orderIxSpiceCards, beforeIxSpiceDraw } from './fixture-ix-spice-cards';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const player = (game: Game, id: string) => { const p = game.players.find(row => row.id === id); assert.ok(p); return p; };
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance;
|
||||
const physical = (game: Game) => [...game.deck, ...game.discard,
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)]
|
||||
.map(card => card.id).sort();
|
||||
|
||||
/** Native public start and original drawn Nexus. Conserved unopened Spice order,
|
||||
* earned Nexus identity and physical card placements are explicitly staged. */
|
||||
function fixture() {
|
||||
let game = createGame('PUBLICNXKULL', newPlayer('c','CHOAM','choam'), false, ['choam','ix']);
|
||||
for (const [id,faction] of [['a','atreides'],['g','guild'],['h','harkonnen']] as const)
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
game = act(game,'c',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
for (const p of game.players) game = act(game,p.id,{type:'ready'});
|
||||
game = act(game,'c',{type:'start'});
|
||||
assert.equal(game.kullPreview,true); assert.equal(game.nexusKullPreview,true);
|
||||
assert.equal(game.spiceDeck.filter(card => 'sandtrout' in card).length,1);
|
||||
game = advanceSemutaNexusTrade(game, state => state.status === 'playing');
|
||||
const inventory = physical(game);
|
||||
// Prevent incidental battles while retaining every physical counter.
|
||||
for (const p of game.players) {p.reserves += Object.values(p.forces).reduce((a,b)=>a+b,0); p.forces = {};}
|
||||
game = advanceSemutaNexusTrade(game, beforeIxSpiceDraw);
|
||||
orderIxSpiceCards(game,['land','land']);
|
||||
game = advanceSemutaNexusTrade(game, state => state.turn === 2 && beforeIxSpiceDraw(state));
|
||||
orderIxSpiceCards(game,['worm','land','land']);
|
||||
game = advanceSemutaNexusTrade(game, state => state.nexus && !state.spiceWindow && clean(state));
|
||||
game = act(game,'a',{type:'alliance',target:'h'}); game = act(game,'h',{type:'alliance',target:'a'});
|
||||
game = advanceSemutaNexusTrade(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state));
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('c'));
|
||||
game = act(game,'c',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
swapSemutaNexusTradeIdentity(game,'c','choam');
|
||||
game = advanceSemutaNexusTrade(game, state => state.turn === 3 && beforeIxSpiceDraw(state));
|
||||
orderIxSpiceCards(game,['sandtrout','land','land']);
|
||||
game = advanceSemutaNexusTrade(game, state => state.phase === 5 && state.active === 'g' && clean(state));
|
||||
assert.equal(game.sandtrout,true); assert.equal(game.nexusCards!.cards!.hands.c,'choam');
|
||||
// Put actual original cards into hands; no card is created or deleted.
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
const printed = takeKullCard(game,'ix-kull-wahad'); player(game,'c').hand.push(printed);
|
||||
const fuel = takeKullCard(game,'ix-hunter-seeker'); player(game,'c').hand.push(fuel);
|
||||
const original = takeKullCard(game,'karama'); player(game,'g').hand.push(original);
|
||||
const counter = takeKullCard(game,'karama'); player(game,'h').hand.push(counter);
|
||||
assert.deepEqual(physical(game),inventory);
|
||||
game = act(game,'g',{type:'card',mode:'shipment',card:original.id,target:'g'});
|
||||
assert.ok(game.pendingKull); assert.equal(game.sandtrout,true);
|
||||
return {game,inventory,printed:printed.id,fuel:fuel.id,original:original.id,counter:counter.id,event:game.pendingKull.event};
|
||||
}
|
||||
|
||||
function passCounter(state: Game): Game {
|
||||
let game=state;
|
||||
while(game.pendingKull) {
|
||||
const seat=game.players.find(p=>{const controls=viewGame(game,p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length>0;});
|
||||
game=seat ? act(game,seat.id,{type:'passResponse'}) : normalizeAutomaticGame(game);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const source of ['printed','nexus'] as const) void test(`public Basic Nexus Kull ${source} succeeds after actual Sandtrout`,()=>{
|
||||
const f=fixture();
|
||||
for(const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(f.game,'c');view.players.find(p=>p.id==='c')!.bot=difficulty;
|
||||
const choice=botActions(view).find(a=>a.type==='kullDecision');assert.ok(choice);
|
||||
assert.doesNotThrow(()=>act(f.game,'c',choice));
|
||||
}
|
||||
let game=act(f.game,'c',{type:'kullDecision',event:f.event,source,card:source==='printed'?f.printed:f.fuel});
|
||||
game=passCounter(game);
|
||||
assert.equal(game.pendingKull,null);assert.equal(game.karamaShipping,null);
|
||||
assert.equal(player(game,'g').hand.some(card=>card.id===f.original),true);
|
||||
assert.equal(game.discard.filter(card=>card.id===(source==='printed'?f.printed:f.fuel)).length,1);
|
||||
assert.equal(game.nexusCards!.cards!.hands.c,source==='printed'?'choam':null);
|
||||
assert.equal(game.sandtrout,true);assert.deepEqual(physical(game),f.inventory);
|
||||
});
|
||||
|
||||
void test('public Basic Nexus Kull decline and distinct counter resume the original Karama',()=>{
|
||||
const f=fixture();
|
||||
const declined=act(f.game,'c',{type:'kullDecision',event:f.event,decline:true});
|
||||
assert.equal(declined.pendingKull,null);assert.equal(declined.karamaShipping?.owner,'g');
|
||||
assert.equal(declined.nexusCards!.cards!.hands.c,'choam');
|
||||
let countered=act(f.game,'c',{type:'kullDecision',event:f.event,source:'nexus',card:f.fuel});
|
||||
countered=act(countered,'h',{type:'card',mode:'cancel',card:f.counter});
|
||||
assert.equal(countered.pendingKull,null);assert.equal(countered.karamaShipping?.owner,'g');
|
||||
assert.equal(player(countered,'c').hand.some(card=>card.id===f.fuel),true);
|
||||
assert.equal(countered.nexusCards!.cards!.hands.c,null);
|
||||
assert.deepEqual(physical(declined),f.inventory);assert.deepEqual(physical(countered),f.inventory);
|
||||
});
|
||||
@@ -81,11 +81,13 @@ void test('Basic Semuta bank purchase uses the supported native roster, not unre
|
||||
joinGame(game, newPlayer('g','Guild','guild'));
|
||||
game.nexusCards = {cards:null,phase:null}; game.semutaPreview = true;
|
||||
assert.equal(emperorNexusBankPurchaseModeSupported(game), true);
|
||||
const unusedIx = structuredClone(game); unusedIx.expansions.push('ix');
|
||||
assert.equal(emperorNexusBankPurchaseModeSupported(unusedIx), true);
|
||||
for (const change of [
|
||||
(g:Game) => { g.advanced = true; },
|
||||
(g:Game) => { g.homeworlds = {custody:null}; },
|
||||
(g:Game) => { g.discoveryEnabled = true; },
|
||||
(g:Game) => { g.expansions.push('ix'); },
|
||||
(g:Game) => { g.expansions.push('ix', 'ix'); },
|
||||
(g:Game) => { g.nexusCards = undefined; },
|
||||
(g:Game) => { joinGame(g,newPlayer('e','Emperor','emperor')); },
|
||||
]) {
|
||||
@@ -131,5 +133,11 @@ for (const claim of [false, true]) for (const use of [false, true]) void test(
|
||||
game = applyAction(game, 'r', use ? {type:'nexusEmperorPurchase',event:originalOffer.event} : {type:'decision',karama:false});
|
||||
assert.equal(semutaNexusTradePlayer(game,'r').spice,spice - (use ? 0 : 2));
|
||||
assert.equal(game.nexusCards!.cards!.hands.r,use ? null : 'emperor');
|
||||
// Paying retains the held Emperor Nexus, so the newly initialized neutral
|
||||
// Ixian Secret Ally purchase window must be passed before the lot closes.
|
||||
if (!use) {
|
||||
assert.ok(game.pendingNexusIxianReplacement);
|
||||
game = applyAction(game, 'r', {type:'nexusIxianReplacementPass',event:game.pendingNexusIxianReplacement.event});
|
||||
}
|
||||
assert.deepEqual(semutaNexusTradeInventory(game),original);
|
||||
});
|
||||
Reference in new issue
Block a user