240 lines
15 KiB
TypeScript
240 lines
15 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, handLimit, 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 { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
|
import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
|
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
|
import {
|
|
createSemutaHomeworldKaramaAuctionFixture, disposeSemutaHomeworldKaramaAuction,
|
|
finishSemutaHomeworldKaramaAuction, type SemutaHomeworldKaramaAuctionFixture,
|
|
} from './fixture-semuta-homeworld-karama-auction';
|
|
|
|
function assertReserved(f: SemutaHomeworldKaramaAuctionFixture, pending: Game) {
|
|
const cost = pending.pendingTreacheryDiscard!;
|
|
assert.equal(cost.continuation.kind, f.scope === 'purchase' ? 'karamaPurchaseDiscard' : 'karamaPaymentDiscard');
|
|
assert.equal(cost.reaction?.stage, 'offer');
|
|
assert.deepEqual(cost.batch.entries.map(entry => ({ id: entry.card.id, owner: entry.discardedBy, public: entry.publicFace })),
|
|
[{ id: f.card, owner: f.actor, public: true }]);
|
|
assert.equal(player(pending, f.actor).hand.some(card => card.id === f.card), false);
|
|
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
|
|
assert.equal(player(pending, f.actor).hand.some(card => card.id === f.reservedLot), false);
|
|
assert.deepEqual(pending.auction, f.game.auction, 'The same native lot and winning bid remain reserved, not delivered');
|
|
assert.deepEqual(pending.players.map(p => ({ player: p.id, spice: p.spice })), f.payers);
|
|
assert.deepEqual(pending.deck, f.game.deck);
|
|
assert.deepEqual(pending.richeseCache, f.game.richeseCache);
|
|
assert.deepEqual(pending.richeseBidding, f.game.richeseBidding);
|
|
assert.deepEqual(pending.homeworlds, f.custody);
|
|
assert.deepEqual(biddingPhysicalIds(pending), biddingPhysicalIds(f.game));
|
|
assert.equal(pending.response, null);
|
|
assert.equal(pending.decision, null);
|
|
}
|
|
|
|
function assertAcquired(f: SemutaHomeworldKaramaAuctionFixture, done: Game, claimed: boolean) {
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
const buyerBefore = player(f.game, f.actor), buyer = player(done, f.actor);
|
|
const lot = f.game.auction!.cards[f.game.auction!.index];
|
|
assert.deepEqual(buyer.hand, [...buyerBefore.hand.filter(card => card.id !== f.card), lot]);
|
|
assert.equal(buyer.hand.filter(card => card.id === f.reservedLot).length, 1);
|
|
assert.deepEqual(done.players.map(p => ({ player: p.id, spice: p.spice })), f.payers,
|
|
'Zero price creates neither Emperor income, foreign occupied share nor refund');
|
|
assert.deepEqual(done.deck, f.game.deck, 'No bonus or replacement card is drawn');
|
|
assert.deepEqual(done.richeseCache, f.game.richeseCache);
|
|
assert.deepEqual(done.richeseRemoved, f.game.richeseRemoved);
|
|
assert.deepEqual(done.richeseBidding, f.game.richeseBidding);
|
|
assert.deepEqual(done.homeworlds, f.custody);
|
|
assert.deepEqual(done.order, f.game.order);
|
|
assert.deepEqual(biddingPhysicalIds(done), biddingPhysicalIds(f.game));
|
|
assert.deepEqual(done.auction!.cards, f.game.auction!.cards);
|
|
assert.equal(done.auction!.index, f.game.auction!.index + 1);
|
|
assert.equal(done.auction!.bid, 0);
|
|
assert.equal(done.auction!.bidder, null);
|
|
assert.equal(player(done, f.holder).hand.filter(card => card.id === f.card).length, Number(claimed));
|
|
assert.equal(done.discard.filter(card => card.id === f.card).length, Number(!claimed));
|
|
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claimed));
|
|
assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claimed);
|
|
}
|
|
|
|
for (const advanced of [false, true]) for (const scope of ['purchase', 'payment'] as const) {
|
|
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld printed Karama ${scope}`;
|
|
void test(`${label}: original cost reserves the lot until an exact singleton claim or pass`, () => {
|
|
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced);
|
|
if (scope === 'payment') {
|
|
assert.equal(f.game.auction!.bidder, f.actor);
|
|
assert.ok(f.nativeBid > player(f.game, f.actor).spice);
|
|
assert.equal(f.game.decision?.kind, 'auctionPayment');
|
|
}
|
|
const pending = disposeSemutaHomeworldKaramaAuction(f);
|
|
assertReserved(f, pending);
|
|
const event = pending.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
|
assert.throws(() => applyAction(pending, f.actor, f.action));
|
|
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${event}-stale` }));
|
|
assert.throws(() => applyAction(pending, f.actor, { type: 'bid', amount: f.nativeBid + 1 }));
|
|
assert.throws(() => applyAction(pending, f.actor, { type: 'decision', karama: false }));
|
|
for (const seat of pending.players.filter(seat => seat.id !== f.holder)) {
|
|
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
|
|
assert.throws(() => applyAction(pending, seat.id, { type: 'semutaCommit', event }));
|
|
}
|
|
for (const claim of [false, true]) {
|
|
const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(pending)) as Game, f.holder, claim);
|
|
assertAcquired(f, done, claim);
|
|
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
|
|
assert.throws(() => applyAction(done, f.actor, f.action));
|
|
for (const seat of done.players.filter(seat => seat.id !== f.actor))
|
|
assert.throws(() => applyAction(done, seat.id, { type: 'decision', karama: true, card: f.card }));
|
|
const finished = finishSemutaHomeworldKaramaAuction(done, f);
|
|
assert.equal(finished.phase, 5);
|
|
assert.equal(finished.turn, f.game.turn);
|
|
assert.equal(finished.richeseBidding!.stage, 'complete');
|
|
assert.equal(finished.richeseCache!.length, f.game.richeseCache!.length - 1,
|
|
'The original compulsory cache auction is completed, never cut from the continuation');
|
|
assert.deepEqual(finished.players.map(p => ({ player: p.id, spice: p.spice })), f.payers);
|
|
assert.equal(player(finished, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
|
|
}
|
|
});
|
|
|
|
void test(`${label}: full holder exchange is atomic and nonholder projections cannot reveal entitlement`, () => {
|
|
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced, true);
|
|
assert.equal(player(f.game, f.holder).hand.length, 4);
|
|
assert.equal(f.game.richeseBidding!.normalCount, f.beforeLot.richeseBidding!.normalCount);
|
|
assert.deepEqual(f.game.auction!.cards, f.beforeLot.auction!.cards);
|
|
const absent = structuredClone(f.game), hand = player(absent, f.holder).hand;
|
|
const index = hand.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
|
const replacement = absent.deck.findIndex(card => card.id !== SEMUTA_DRUG_ID);
|
|
[hand[index], absent.deck[replacement]] = [absent.deck[replacement], hand[index]];
|
|
assert.deepEqual(biddingPhysicalIds(absent), biddingPhysicalIds(f.game));
|
|
const pending = disposeSemutaHomeworldKaramaAuction(f), neutral = disposeSemutaHomeworldKaramaAuction(f, absent);
|
|
assertReserved(f, pending);
|
|
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
|
for (const seat of pending.players.filter(seat => seat.id !== f.holder))
|
|
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
|
|
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))));
|
|
assert.throws(() => applyAction(neutral, f.holder,
|
|
{ type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event }));
|
|
const done = reactSemutaHomeworldChoam(pending, f.holder, true);
|
|
assertAcquired(f, done, true);
|
|
assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)));
|
|
const declined = reactSemutaHomeworldChoam(neutral, f.holder, false);
|
|
assert.equal(player(declined, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
|
|
assert.deepEqual(declined.players.map(p => ({ player: p.id, spice: p.spice })), f.payers);
|
|
});
|
|
|
|
void test(`${label}: changed reserved native settlement rejects without effect replay across JSON`, () => {
|
|
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced);
|
|
const pending = disposeSemutaHomeworldKaramaAuction(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
|
const mutations: ((game: Game) => void)[] = [
|
|
game => { game.auction!.cards[game.auction!.index].name = 'Changed reserved face'; },
|
|
game => { game.richeseBidding!.position = 'first'; },
|
|
game => { game.auction!.bidder = f.holder; },
|
|
game => {
|
|
const continuation = game.pendingTreacheryDiscard!.continuation;
|
|
if (continuation.kind === 'karamaPurchaseDiscard' || continuation.kind === 'karamaPaymentDiscard')
|
|
continuation.stateSignature = 'retired';
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const altered = JSON.parse(JSON.stringify(pending)) as Game;
|
|
mutate(altered);
|
|
const original = JSON.stringify(altered);
|
|
assert.throws(() => applyAction(altered, f.holder, { type: 'semutaCommit', event }));
|
|
assert.equal(JSON.stringify(altered), original, 'Rejected altered continuation neither settles nor spends Semuta');
|
|
}
|
|
for (const seat of f.game.players.filter(seat => seat.id !== f.actor))
|
|
assert.throws(() => disposeSemutaHomeworldKaramaAuction({ ...f, actor: seat.id }));
|
|
});
|
|
|
|
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: ${label} consumes a minimal legal private reaction then native next-lot control`, () => {
|
|
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced);
|
|
let game = disposeSemutaHomeworldKaramaAuction(f);
|
|
const own = viewGame(game, f.holder);
|
|
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
|
const reaction = botActions(own)[0];
|
|
assert.ok(reaction?.type === 'semutaCommit' || reaction?.type === 'semutaPass');
|
|
game = applyAction(game, f.holder, reaction);
|
|
if (game.pendingTreacheryDiscard) {
|
|
const pending = game.pendingTreacheryDiscard, offer = pending.reaction;
|
|
assert.ok(offer?.stage === 'offer');
|
|
for (const seat of game.players.filter(seat => !offer.passed.includes(seat.id)))
|
|
game = applyAction(game, seat.id, { type: 'semutaPass', event: pending.batch.event });
|
|
}
|
|
assertAcquired(f, game, reaction.type === 'semutaCommit');
|
|
game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision);
|
|
const actor = game.auction!.active, native = viewGame(game, actor);
|
|
native.players.find(seat => seat.id === actor)!.bot = difficulty;
|
|
const action = botActions(native)[0];
|
|
assert.ok(action);
|
|
const moved = applyAction(game, actor, action);
|
|
assert.equal(player(moved, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
|
|
const done = finishSemutaHomeworldKaramaAuction(moved, f);
|
|
assert.equal(done.phase, 5);
|
|
assert.equal(done.richeseBidding!.stage, 'complete');
|
|
assert.equal(player(done, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
|
|
});
|
|
}
|
|
|
|
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: native buyer capacity distinguishes direct purchase from spending a winning-payment Karama`, () => {
|
|
for (const scope of ['purchase', 'payment'] as const) {
|
|
const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced), game = structuredClone(f.game);
|
|
const buyer = player(game, f.actor);
|
|
while (buyer.hand.length < handLimit(buyer)) buyer.hand.push(game.deck.shift()!);
|
|
assert.equal(buyer.hand.length, handLimit(buyer));
|
|
assert.deepEqual(biddingPhysicalIds(game), biddingPhysicalIds(f.game));
|
|
if (scope === 'purchase') {
|
|
const original = JSON.stringify(game);
|
|
assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game));
|
|
assert.equal(JSON.stringify(game), original);
|
|
} else {
|
|
const cost = disposeSemutaHomeworldKaramaAuction(f, game);
|
|
assert.equal(player(cost, f.actor).hand.length, handLimit(buyer) - 1);
|
|
assert.equal(player(cost, f.actor).hand.some(card => card.id === f.reservedLot), false);
|
|
const done = reactSemutaHomeworldChoam(cost, f.holder, true);
|
|
assert.equal(player(done, f.actor).hand.length, handLimit(buyer));
|
|
assert.equal(player(done, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1);
|
|
assert.deepEqual(done.players.map(p => ({ player: p.id, spice: p.spice })), f.payers);
|
|
}
|
|
}
|
|
});
|
|
|
|
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: original printed cache lot still cannot be taken with normal-auction Karama`, () => {
|
|
const f = createSemutaHomeworldKaramaAuctionFixture('purchase', advanced);
|
|
let game = advanceSemutaHomeworldChoam(f.game, state => state.decision?.kind === 'richeseCache');
|
|
game = applyAction(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
|
|
card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise' });
|
|
game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision);
|
|
assert.equal(game.richeseAuction!.source, 'cache');
|
|
assert.equal(game.auction, null);
|
|
const original = JSON.stringify(game);
|
|
assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game));
|
|
assert.equal(JSON.stringify(game), original);
|
|
assert.equal(player(game, f.actor).hand.filter(card => card.id === f.card).length, 1);
|
|
});
|
|
|
|
void test('Advanced: genuine original normal-method Black Market still cannot be taken by normal-auction Karama', () => {
|
|
const f = createSemutaHomeworldKaramaAuctionFixture('purchase', true);
|
|
let game = structuredClone(f.setup);
|
|
if (!player(game, f.actor).hand.some(card => card.id === f.card))
|
|
moveSemutaEmperorCard(game, f.card, f.actor);
|
|
// These are the same still-unplayed physical cards, before any first-turn blow.
|
|
for (let position = 0; position < 2; position++) {
|
|
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
|
|
assert.ok(index >= position);
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
game = advanceSemutaHomeworldChoam(game, state => state.decision?.kind === 'richeseBlackMarket');
|
|
const owner = game.decision!.player, lot = player(game, owner).hand[0];
|
|
assert.ok(lot);
|
|
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event,
|
|
card: lot.id, method: 'normal' });
|
|
game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision);
|
|
assert.equal(game.richeseAuction!.source, 'blackMarket');
|
|
assert.equal(game.richeseAuction!.method, 'normal');
|
|
assert.equal(game.auction, null);
|
|
const original = JSON.stringify(game);
|
|
assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game));
|
|
assert.equal(JSON.stringify(game), original);
|
|
assert.equal(player(game, f.actor).hand.filter(card => card.id === f.card).length, 1);
|
|
});
|