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[]) 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); });