Continue Kull through paid auction bonuses
Verify and publish checkpoint container / container (push) Successful in 10m25s
Verify and publish checkpoint container / container (push) Successful in 10m25s
This commit is contained in:
1 parent
6e1b95d2fc
commit
3d2470b7f8
8 files changed
+363
-5
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** Uses the public Basic Kull/Richese setup and an actual paid first-turn
|
||||
* cache acquisition of Semuta. The fixture selects only unopened initial
|
||||
* Treachery/Spice positions; all subsequent phases and the next cache
|
||||
* declaration use native actions. */
|
||||
function source(position: 'first' | 'last') {
|
||||
const f = createKullRicheseSemutaFixture();
|
||||
let game = advance(f.game, state => state.turn === 2 && state.phase === 3 &&
|
||||
!state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration');
|
||||
const declaration = game.richeseBidding; assert.ok(declaration);
|
||||
game = act(game, 'richese', {type:'decision', event:declaration.event, position});
|
||||
assert.equal(game.response?.kind, 'richeseAuction');
|
||||
assert.equal(game.response?.owner, 'richese');
|
||||
assert.equal(game.richeseBidding?.position, position);
|
||||
assert.equal(game.richeseBidding?.cacheCanceled, false);
|
||||
assert.ok(player(game, 'richese').hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama));
|
||||
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.counter));
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === f.kull));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return {...f, game, cache:game.richeseCache!.map(card => card.id),
|
||||
normalBefore:game.richeseBidding!.normalCountBeforeCache!,
|
||||
normalCount:game.richeseBidding!.normalCount!};
|
||||
}
|
||||
|
||||
function passDiscard(game: Game): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer');
|
||||
const event = pending.batch.event;
|
||||
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;
|
||||
}
|
||||
|
||||
function claimDiscard(game: Game, card: string): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer');
|
||||
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
|
||||
assert.equal(viewGame(game, 'richese').semutaReaction?.canCommit, true);
|
||||
const event = pending.batch.event;
|
||||
game = act(game, 'richese', {type:'semutaCommit', event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, 'richese', {type:'semutaSelect', event, card});
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const position of ['first', 'last'] as const)
|
||||
for (const outcome of ['decline', 'success', 'counter'] as const)
|
||||
for (const claim of [false, true])
|
||||
void test(`Basic ${position} cache cancellation: Kull ${outcome}, Semuta ${claim ? 'claim' : 'pass'}`, () => {
|
||||
const f = source(position); let game = f.game;
|
||||
assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama));
|
||||
game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama});
|
||||
const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
|
||||
game = outcome === 'decline'
|
||||
? act(game, 'choam', {type:'kullDecision', event:reaction.event, decline:true})
|
||||
: act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull});
|
||||
if (outcome === 'counter') {
|
||||
assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter));
|
||||
game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter});
|
||||
game = passDiscard(game);
|
||||
}
|
||||
if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard);
|
||||
const discarded = outcome === 'success' ? f.kull : f.karama;
|
||||
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded));
|
||||
game = claim ? claimDiscard(game, discarded) : passDiscard(game);
|
||||
assert.ok(!game.pendingKull);
|
||||
if (outcome === 'success') {
|
||||
assert.equal(game.response?.kind, 'richeseAuction');
|
||||
assert.equal(game.richeseBidding?.cacheCanceled, false);
|
||||
assert.equal(game.richeseBidding?.normalCount, f.normalCount);
|
||||
assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama));
|
||||
} else {
|
||||
game = advance(game, state => state.phase === 3 && state.richeseBidding?.stage === 'normal' &&
|
||||
!!state.auction && clean(state));
|
||||
assert.equal(game.richeseBidding?.cacheCanceled, true);
|
||||
assert.equal(game.richeseBidding?.normalCount, f.normalBefore);
|
||||
assert.equal(game.auction?.cards.length, f.normalBefore);
|
||||
assert.ok(!player(game, 'emperor').hand.some(card => card.id === f.karama));
|
||||
}
|
||||
assert.equal(player(game, 'richese').hand.some(card => card.id === discarded), claim);
|
||||
assert.equal(player(game, 'richese').hand.some(card => card.id === f.semuta), !claim);
|
||||
assert.deepEqual(game.richeseCache?.map(card => card.id), f.cache);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
/** Atreides inspection of a concealed Richese lot belongs to Advanced Black
|
||||
* Market, not Basic's face-up cache. This Basic parent instead reaches the
|
||||
* native Harkonnen bonus response after a real paid cache purchase. */
|
||||
function bonusSource() {
|
||||
const f = source('first'); let game = f.game;
|
||||
for (let count = 0; game.response?.kind === 'richeseAuction' && count < game.players.length; count++) {
|
||||
const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat);
|
||||
game = act(game, seat.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'richeseCache');
|
||||
const card = game.richeseCache?.[0]; assert.ok(card);
|
||||
game = act(game, 'richese', {type:'decision', event:game.richeseBidding!.event,
|
||||
card:card.id, method:'silent'});
|
||||
const lot = game.richeseAuction; assert.ok(lot && lot.method === 'silent');
|
||||
for (let count = 0; game.richeseAuction?.event === lot.event && !game.response && count < game.players.length; count++) {
|
||||
const current = game.richeseAuction!;
|
||||
const actor = current.eligible.find(id => !Object.hasOwn(current.sealed,id)); assert.ok(actor);
|
||||
game = act(game, actor, {type:'richeseBid', event:current.event,
|
||||
amount:actor === 'harkonnen' ? 1 : 0, allyPayment:0});
|
||||
}
|
||||
assert.equal(game.response?.kind, 'harkonnenBonus');
|
||||
assert.equal(game.response?.owner, 'harkonnen');
|
||||
assert.ok(game.richeseAuction);
|
||||
assert.equal(game.currentAuctionSale?.origin, 'cache');
|
||||
assert.equal(game.currentAuctionSale?.winner, 'harkonnen');
|
||||
assert.ok(player(game, 'harkonnen').hand.some(held => held.id === card.id));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return {...f, game, purchased:card.id};
|
||||
}
|
||||
|
||||
for (const outcome of ['decline', 'success', 'counter'] as const)
|
||||
for (const claim of [false, true])
|
||||
void test(`Basic paid cache Harkonnen bonus cancellation: Kull ${outcome}, Semuta ${claim ? 'claim' : 'pass'}`, () => {
|
||||
const f = bonusSource(); let game = f.game;
|
||||
const harkonnenCards = player(game, 'harkonnen').hand.length;
|
||||
assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama));
|
||||
game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama});
|
||||
const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
|
||||
game = outcome === 'decline'
|
||||
? act(game, 'choam', {type:'kullDecision', event:reaction.event, decline:true})
|
||||
: act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull});
|
||||
if (outcome === 'counter') {
|
||||
assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter));
|
||||
game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter});
|
||||
game = passDiscard(game);
|
||||
}
|
||||
if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard);
|
||||
const discarded = outcome === 'success' ? f.kull : f.karama;
|
||||
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded));
|
||||
game = claim ? claimDiscard(game, discarded) : passDiscard(game);
|
||||
assert.ok(!game.pendingKull);
|
||||
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.purchased));
|
||||
if (outcome === 'success') {
|
||||
// With no other eligible cancellation remaining, the restored bonus
|
||||
// resolves automatically and the winner draws its real extra card.
|
||||
assert.ok(!game.currentAuctionSale);
|
||||
assert.equal(player(game, 'harkonnen').hand.length, harkonnenCards + 1);
|
||||
assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama));
|
||||
} else {
|
||||
assert.ok(!game.currentAuctionSale);
|
||||
assert.ok(!player(game, 'emperor').hand.some(card => card.id === f.karama));
|
||||
}
|
||||
assert.equal(player(game, 'richese').hand.some(card => card.id === discarded), claim);
|
||||
assert.equal(player(game, 'richese').hand.some(card => card.id === f.semuta), !claim);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('the paid cache sale and source remain bound while Kull and Semuta are pending', () => {
|
||||
const f = bonusSource();
|
||||
let game = act(f.game, 'emperor', {type:'card', mode:'cancel', card:f.karama});
|
||||
const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction);
|
||||
const staleSale = structuredClone(game); staleSale.currentAuctionSale!.amount++;
|
||||
assert.throws(() => act(staleSale, 'choam', {type:'kullDecision', event:reaction.event, decline:true}));
|
||||
const staleCache = structuredClone(game); staleCache.richeseCache!.reverse();
|
||||
assert.throws(() => act(staleCache, 'choam', {type:'kullDecision', event:reaction.event, decline:true}));
|
||||
game = act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull});
|
||||
game = advance(game, state => !!state.pendingTreacheryDiscard);
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
const changed = structuredClone(game); changed.currentAuctionSale!.amount++;
|
||||
assert.throws(() => act(changed, 'richese', {type:'semutaPass', event:pending.batch.event}));
|
||||
game = passDiscard(game);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all existing bot profiles can answer the paid cache bonus Kull offer legally', () => {
|
||||
const f = bonusSource();
|
||||
const game = act(f.game, 'emperor', {type:'card', mode:'cancel', card:f.karama});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'choam');
|
||||
view.players.find(seat => seat.id === 'choam')!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action, difficulty);
|
||||
assert.equal(action.type, 'kullDecision', difficulty);
|
||||
assert.doesNotThrow(() => act(game, 'choam', action), difficulty);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { act, advance, clean, createKullRicheseSemutaFixture, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** Account for the current sold card as a historical auction reference, not a
|
||||
* second physical card. Unopened lots remain in the original auction custody. */
|
||||
function inventory(game: Game): string[] {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(seat => seat.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(cards).size, cards.length);
|
||||
return cards;
|
||||
}
|
||||
|
||||
/** Public Basic four-seat setup and actual first-turn paid Semuta acquisition.
|
||||
* In turn two Richese announces its compulsory auction last, then Harkonnen
|
||||
* purchases an actual normal lot with its own spice; no live purse, hand,
|
||||
* auction, response or promise is staged. */
|
||||
function source() {
|
||||
const f = createKullRicheseSemutaFixture();
|
||||
let game = advance(f.game, state => state.turn === 2 && state.phase === 3 &&
|
||||
!state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration');
|
||||
const round = game.richeseBidding; assert.ok(round);
|
||||
game = act(game, 'richese', {type:'decision', event:round.event, position:'last'});
|
||||
game = advance(game, state => state.phase === 3 && !!state.auction &&
|
||||
state.auction.active === 'harkonnen' && state.auction.bid === 0 && clean(state));
|
||||
const index = game.auction!.index, lot = game.auction!.cards[index].id;
|
||||
const beforeSpice = player(game,'harkonnen').spice;
|
||||
game = act(game,'harkonnen',{type:'bid',amount:1,allyPayment:0});
|
||||
game = advance(game,state => state.response?.kind === 'harkonnenBonus');
|
||||
assert.equal(game.currentAuctionSale?.origin,'normal');
|
||||
assert.equal(game.currentAuctionSale?.winner,'harkonnen');
|
||||
assert.equal(game.auction?.index,index);
|
||||
assert.equal(game.auction?.cards[index].id,lot);
|
||||
assert.ok(player(game,'harkonnen').hand.some(card => card.id === lot));
|
||||
assert.equal(player(game,'harkonnen').spice,beforeSpice-1);
|
||||
assert.ok(player(game,'choam').hand.some(card => card.id === f.kull));
|
||||
assert.ok(player(game,'emperor').hand.some(card => card.id === f.karama));
|
||||
assert.ok(player(game,'harkonnen').hand.some(card => card.id === f.counter));
|
||||
assert.ok(player(game,'richese').hand.some(card => card.id === f.semuta));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
return {...f,game,index,lot,paidSpice:player(game,'harkonnen').spice,
|
||||
buyerCount:player(game,'harkonnen').hand.length};
|
||||
}
|
||||
|
||||
function passDiscard(game: Game): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer');
|
||||
const event = pending.batch.event;
|
||||
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;
|
||||
}
|
||||
function claimDiscard(game: Game, card: string): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer');
|
||||
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
|
||||
assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true);
|
||||
const event=pending.batch.event;
|
||||
game=act(game,'richese',{type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game=act(game,'richese',{type:'semutaSelect',event,card});
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const outcome of ['decline','success','counter'] as const)
|
||||
for (const claim of [false,true])
|
||||
void test(`normal Harkonnen paid bonus: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`,()=>{
|
||||
const f=source(); let game=f.game;
|
||||
assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama));
|
||||
game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama});
|
||||
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(play=>play.source==='printed' && play.card.id===f.kull));
|
||||
game=outcome==='decline'
|
||||
? act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true})
|
||||
: act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
|
||||
if (outcome==='counter') {
|
||||
assert.ok(viewGame(game,'harkonnen').responseControls?.cancelCards.includes(f.counter));
|
||||
game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter});
|
||||
game=passDiscard(game);
|
||||
}
|
||||
if (outcome==='success') game=advance(game,state=>!!state.pendingTreacheryDiscard);
|
||||
const discarded=outcome==='success'?f.kull:f.karama;
|
||||
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry=>entry.card.id===discarded));
|
||||
game=claim?claimDiscard(game,discarded):passDiscard(game);
|
||||
assert.equal(game.pendingKull ?? null,null);
|
||||
assert.equal(game.currentAuctionSale ?? null,null);
|
||||
assert.notEqual(game.response?.kind,'harkonnenBonus');
|
||||
assert.equal(game.auction?.index,f.index+1);
|
||||
assert.equal(player(game,'harkonnen').spice,f.paidSpice);
|
||||
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.lot));
|
||||
assert.equal(player(game,'harkonnen').hand.length,
|
||||
f.buyerCount+Number(outcome==='success')-Number(outcome==='counter'));
|
||||
assert.equal(player(game,'emperor').hand.some(card=>card.id===f.karama),outcome==='success');
|
||||
assert.equal(player(game,'richese').hand.some(card=>card.id===discarded),claim);
|
||||
assert.equal(player(game,'richese').hand.some(card=>card.id===f.semuta),!claim);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user