201 lines
11 KiB
TypeScript
201 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { advanceAuctionClock, hasAuctionClockOpportunity, synchronizeAuctionClock,
|
|
viewGame, type Game } from '../game/engine';
|
|
import {
|
|
actSemutaRicheseCacheKarama as act,
|
|
advanceSemutaRicheseCacheKarama as advance,
|
|
createSemutaRicheseCacheKaramaFixture as fixture,
|
|
semutaRicheseCacheKaramaInventory as inventory,
|
|
} from './fixture-semuta-richese-cache-karama';
|
|
import { createTruthRicheseBidFixture, bindTruthRicheseBid, declareTruthRicheseBid,
|
|
passTruthRicheseFreshDiscard } from './fixture-truth-richese-bid';
|
|
import { createSaphoCyclicAuctionFixture, actSaphoCyclic, saphoCyclicAction,
|
|
saphoCyclicBid } from './fixture-sapho-cyclic-auction';
|
|
|
|
const epoch = '0:0';
|
|
type Method = 'normal' | 'onceAround' | 'silent';
|
|
|
|
/** Genuine Basic cache: Richese previously sold Semuta and now declares its
|
|
* second-turn first cache lot. The fixture selects only unopened card/spice
|
|
* positions; this helper takes the original response and owner decisions. */
|
|
function cache(method: 'onceAround' | 'silent'): Game {
|
|
const f = fixture({position:'first'});
|
|
let game = advance(f.game, state => !state.response && !state.phaseOpening &&
|
|
state.decision?.kind === 'richeseCache');
|
|
const card = game.richeseCache?.[0]; assert.ok(card && game.richeseBidding);
|
|
game = act(game, 'richese', {type:'decision', event:game.richeseBidding.event,
|
|
card:card.id, method, direction:'clockwise'});
|
|
assert.equal(game.richeseAuction?.source, 'cache');
|
|
assert.equal(game.richeseAuction?.method, method);
|
|
assert.ok(!game.response && !game.decision && !game.phaseOpening);
|
|
return game;
|
|
}
|
|
|
|
/** An actual original Advanced game reaches its first Black Market decision.
|
|
* The original owner offers a real held card and all response/inspection
|
|
* windows are resolved through the existing native action driver. */
|
|
function blackMarket(method: Method): Game {
|
|
const f = fixture({advanced:true});
|
|
let game = structuredClone(f.afterSetup);
|
|
const front: Game['spiceDeck'] = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
|
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index,1)[0]);
|
|
}
|
|
game.spiceDeck.unshift(...front);
|
|
game = advance(game, state => !state.response && !state.phaseOpening &&
|
|
state.decision?.kind === 'richeseBlackMarket');
|
|
const owner = game.players.find(player => player.id === 'richese');
|
|
assert.ok(owner && owner.hand.length && game.richeseBidding);
|
|
game = act(game, 'richese', {type:'decision', event:game.richeseBidding.event,
|
|
card:owner.hand[0].id, method, ...(method === 'onceAround' ? {direction:'clockwise'} : {})});
|
|
game = advance(game, state => state.richeseAuction?.source === 'blackMarket' &&
|
|
state.richeseAuction.method === method && !state.response && !state.phaseOpening && !state.decision);
|
|
assert.equal(game.richeseAuction?.source, 'blackMarket');
|
|
return game;
|
|
}
|
|
|
|
for (const source of ['cache', 'blackMarket'] as const)
|
|
for (const method of (source === 'cache' ? ['onceAround'] : ['normal','onceAround']) as Exclude<Method,'silent'>[])
|
|
void test(`${source} ${method} expires at the deadline through its native pass`,()=>{
|
|
const initial = source === 'cache' ? cache('onceAround') : blackMarket(method);
|
|
const lot = initial.richeseAuction; assert.ok(lot && lot.active);
|
|
const original = structuredClone(initial), originalCards = inventory(initial);
|
|
assert.equal(hasAuctionClockOpportunity(initial),true);
|
|
const armed = synchronizeAuctionClock(initial,1_000,epoch);
|
|
assert.equal(armed.auctionClock?.player,lot.active);
|
|
assert.equal(armed.auctionClock?.remainingMs,10_000);
|
|
assert.equal(armed.auctionClock?.startedAt,1_000);
|
|
assert.equal(advanceAuctionClock(armed,10_999,epoch),armed);
|
|
const expected = act(initial,lot.active,{type:'richeseBid',event:lot.event,amount:null});
|
|
const expired = advanceAuctionClock(armed,11_000,epoch);
|
|
assert.deepEqual(expired.richeseAuction,expected.richeseAuction);
|
|
assert.equal(expired.active,expected.active);
|
|
assert.ok(expired.richeseAuction?.passed.includes(lot.active));
|
|
assert.notEqual(expired.auctionClock?.event,armed.auctionClock?.event);
|
|
assert.equal(expired.auctionClock?.remainingMs,10_000);
|
|
assert.equal(expired.auctionClock?.startedAt,11_000);
|
|
assert.deepEqual(inventory(expired),originalCards);
|
|
assert.deepEqual(initial,original);
|
|
});
|
|
|
|
void test('a native special-lot bid resets the next bidder and an administrative pause preserves the remainder',()=>{
|
|
const initial=blackMarket('normal'), lot=initial.richeseAuction; assert.ok(lot?.active);
|
|
let game=synchronizeAuctionClock(initial,1_000,epoch);
|
|
const original=game.auctionClock!.event;
|
|
game=act(game,lot.active,{type:'richeseBid',event:lot.event,amount:1,allyPayment:0});
|
|
game=synchronizeAuctionClock(game,2_000,epoch);
|
|
assert.notEqual(game.auctionClock!.event,original);
|
|
assert.equal(game.auctionClock!.player,game.richeseAuction!.active);
|
|
game=synchronizeAuctionClock(game,5_000,epoch,true);
|
|
assert.equal(game.auctionClock!.remainingMs,7_000); assert.equal(game.auctionClock!.startedAt,null);
|
|
const held=advanceAuctionClock(game,50_000,epoch,true); assert.deepEqual(held,game);
|
|
game=synchronizeAuctionClock(held,50_000,epoch);
|
|
assert.equal(game.auctionClock!.remainingMs,7_000); assert.equal(game.auctionClock!.startedAt,50_000);
|
|
assert.equal(advanceAuctionClock(game,56_999,epoch),game);
|
|
const actor=game.richeseAuction!.active!;
|
|
const expected=act(game,actor,{type:'richeseBid',event:game.richeseAuction!.event,amount:null});
|
|
const expired=advanceAuctionClock(game,57_000,epoch);
|
|
assert.deepEqual(expired.richeseAuction,expected.richeseAuction);
|
|
});
|
|
|
|
for (const source of ['cache','blackMarket'] as const)
|
|
void test(`${source} Silent auction is simultaneous and has no sequential bidding clock`,()=>{
|
|
const game=source==='cache'?cache('silent'):blackMarket('silent');
|
|
const lot=game.richeseAuction; assert.ok(lot && lot.active===null);
|
|
assert.equal(hasAuctionClockOpportunity(game),false);
|
|
assert.equal(synchronizeAuctionClock(game,1_000,epoch),game);
|
|
assert.equal(advanceAuctionClock(game,1_000_000,epoch),game);
|
|
for (const seat of game.players) assert.equal(viewGame(game,seat.id,10_000).auctionClock,null);
|
|
});
|
|
|
|
void test('the special-lot clock exposes only public countdown fields, never a Black Market card identity',()=>{
|
|
const source=blackMarket('onceAround'), lot=source.richeseAuction; assert.ok(lot && lot.active);
|
|
const game=synchronizeAuctionClock(source,1_000,epoch);
|
|
for (const seat of game.players) {
|
|
const view=viewGame(game,seat.id,3_250);
|
|
assert.deepEqual(Object.keys(view.auctionClock!).sort(),['event','player','remainingMs','running']);
|
|
assert.equal(view.auctionClock!.remainingMs,7_750);
|
|
assert.equal(view.auctionClock!.player,lot.active);
|
|
assert.ok(!JSON.stringify(view.auctionClock).includes(lot.cardId));
|
|
}
|
|
});
|
|
|
|
void test('a previously opened unstamped special lot is not retroactively timed',()=>{
|
|
const game=blackMarket('onceAround');
|
|
assert.ok(game.richeseAuction);
|
|
const old=structuredClone(game);
|
|
delete old.richeseAuction!.clockVersion;
|
|
assert.equal(hasAuctionClockOpportunity(old),false);
|
|
assert.equal(synchronizeAuctionClock(old,1_000,epoch),old);
|
|
assert.equal(advanceAuctionClock(old,1_000_000,epoch),old);
|
|
assert.equal(viewGame(old,'richese',1_000_000).auctionClock,null);
|
|
});
|
|
|
|
void test('a Once Around timeout honors the original binding Truthtrance Yes with the legal required bid',()=>{
|
|
const f=createTruthRicheseBidFixture();
|
|
const source=act(f.game,f.asker,{type:'richeseBid',event:f.lot,amount:null});
|
|
assert.equal(source.richeseAuction?.active,f.target);
|
|
const game=bindTruthRicheseBid(f,'yes',4,source).game;
|
|
const guide=viewGame(game,f.target).richeseBidPromiseGuide; assert.ok(guide);
|
|
assert.equal(guide.action.type,'richeseBid'); assert.equal(guide.action.amount,4);
|
|
const armed=synchronizeAuctionClock(game,1_000,epoch);
|
|
const expired=advanceAuctionClock(armed,11_000,epoch);
|
|
assert.equal(expired.richeseAuction?.bid,4);
|
|
assert.equal(expired.richeseAuction?.bidder,f.target);
|
|
assert.ok(expired.richeseBidPromises?.some(row=>row.player===f.target && row.fulfilled));
|
|
assert.deepEqual(inventory(expired),f.originalCards);
|
|
});
|
|
|
|
void test('a Once Around timeout may pass when a binding Truthtrance No forbids the qualifying bid',()=>{
|
|
const f=createTruthRicheseBidFixture();
|
|
const source=act(f.game,f.asker,{type:'richeseBid',event:f.lot,amount:null});
|
|
assert.equal(source.richeseAuction?.active,f.target);
|
|
const game=bindTruthRicheseBid(f,'no',4,source).game;
|
|
const expected=act(game,f.target,{type:'richeseBid',event:f.lot,amount:null});
|
|
const armed=synchronizeAuctionClock(game,1_000,epoch);
|
|
const expired=advanceAuctionClock(armed,11_000,epoch);
|
|
assert.deepEqual(expired.richeseAuction,expected.richeseAuction);
|
|
assert.ok(expired.richeseAuction?.passed.includes(f.target));
|
|
assert.notEqual(expired.richeseAuction?.bidder,f.target);
|
|
assert.deepEqual(inventory(expired),f.originalCards);
|
|
});
|
|
|
|
void test('Sapho moving another Black Market bidder last does not refresh the unchanged current bidder',()=>{
|
|
const f=createSaphoCyclicAuctionFixture({advanced:true,blackMarket:true});
|
|
const first=saphoCyclicBid(f.game,1);
|
|
let game=actSaphoCyclic(f.game,first.actor,first.action);
|
|
const lot=game.richeseAuction; assert.ok(lot?.active);
|
|
assert.notEqual(lot.active,f.owner,'The Sapho owner is not the current bidder');
|
|
const action=saphoCyclicAction(game,'last');
|
|
game=synchronizeAuctionClock(game,1_000,epoch);
|
|
const original=game.auctionClock!;
|
|
const next=actSaphoCyclic(game,f.owner,action);
|
|
assert.equal(next.richeseAuction?.active,lot.active);
|
|
game=synchronizeAuctionClock(next,3_000,epoch);
|
|
assert.equal(game.auctionClock?.event,original.event);
|
|
assert.equal(game.auctionClock?.player,original.player);
|
|
assert.equal(viewGame(game,f.owner,3_000).auctionClock?.remainingMs,8_000);
|
|
assert.equal(game.auctionClock?.startedAt,1_000);
|
|
});
|
|
|
|
void test('the native Truthtrance interruption holds and resumes the same Once Around clock',()=>{
|
|
const f=createTruthRicheseBidFixture(), armed=synchronizeAuctionClock(f.game,1_000,epoch);
|
|
const declared=declareTruthRicheseBid(f,armed), held=synchronizeAuctionClock(declared,3_000,epoch);
|
|
assert.equal(held.auctionClock!.event,armed.auctionClock!.event);
|
|
assert.equal(held.auctionClock!.remainingMs,8_000); assert.equal(held.auctionClock!.startedAt,null);
|
|
assert.deepEqual(advanceAuctionClock(held,50_000,epoch),held);
|
|
let game=act(held,f.asker,{type:'truthAsk',question:{kind:'richeseBid',target:f.target,
|
|
turn:held.turn,event:f.lot,minimum:4}});
|
|
game=act(game,f.target,{type:'truthAnswer',answer:'no'});
|
|
game=passTruthRicheseFreshDiscard(game);
|
|
game=synchronizeAuctionClock(game,50_000,epoch);
|
|
assert.equal(game.auctionClock!.event,armed.auctionClock!.event);
|
|
assert.equal(game.auctionClock!.remainingMs,8_000); assert.equal(game.auctionClock!.startedAt,50_000);
|
|
assert.equal(advanceAuctionClock(game,57_999,epoch),game);
|
|
const expired=advanceAuctionClock(game,58_000,epoch);
|
|
assert.ok(expired.richeseAuction?.passed.includes(f.asker));
|
|
assert.deepEqual(inventory(expired),f.originalCards);
|
|
});
|