Support Semuta after Box searches during bidding
This commit is contained in:
1 parent
7dfdf8d593
commit
6bf165a6aa
8 files changed
+312
-2
No files matched your search
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createSemutaAuctionInspectionFixture,
|
||||
semutaAuctionInspectionInventory as inventory,
|
||||
semutaAuctionInspectionPlayer as player,
|
||||
} from './fixture-semuta-auction-inspection';
|
||||
|
||||
const BOX = 'richese-nullentropy-box';
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
|
||||
function rejectWithoutMutation(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Real public Basic setup, paid cache Semuta acquisition, and a later real
|
||||
* ordinary auction. This supplemental boundary deliberately stages a mutual
|
||||
* alliance and relocates the physical Box plus two physical discards; no money,
|
||||
* auction state, counters, phase or saved continuation is manufactured. */
|
||||
function fixture() {
|
||||
const source = createSemutaAuctionInspectionFixture();
|
||||
let game = structuredClone(source.game);
|
||||
while (game.response?.kind === 'atreidesAuction') {
|
||||
const actor = game.players.find(row => !game.response!.passed.includes(row.id));
|
||||
assert.ok(actor);
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.ok(game.auction && !game.richeseAuction && !game.currentAuctionSale);
|
||||
const bidder = game.auction.active;
|
||||
const owner = game.players.find(row => row.id !== bidder && row.id !== source.holder &&
|
||||
row.spice >= 2 && row.hand.length < handLimit(row));
|
||||
assert.ok(owner, 'A non-bidding, non-Semuta holder must afford the staged Box.');
|
||||
const donor = game.players.find(row => row.id !== bidder && row.id !== owner.id && row.spice >= 1);
|
||||
assert.ok(donor, 'A distinct player must fund the standing auction bid.');
|
||||
assert.ok(!player(game, bidder).ally && !donor.ally, 'The supplemental alliance must not replace a real pact.');
|
||||
player(game, bidder).ally = donor.id;
|
||||
donor.ally = bidder;
|
||||
game = applyAction(game, donor.id, { type: 'pledgeAid', amount: 1 });
|
||||
game = applyAction(game, bidder, { type: 'bid', amount: 1, allyPayment: 1 });
|
||||
assert.equal(game.auction?.bidder, bidder);
|
||||
assert.equal(game.auction?.bid, 1);
|
||||
assert.equal(game.auction?.allyPayment, 1);
|
||||
assert.equal(game.aid[donor.id]?.amount, 1);
|
||||
|
||||
const zones = [game.deck, game.discard, game.richeseCache!, game.richeseRemoved!,
|
||||
...game.players.map(row => row.hand)].filter(Boolean);
|
||||
const boxZone = zones.find(zone => zone.some(card => card.id === BOX));
|
||||
assert.ok(boxZone, 'The original physical Box must be in a movable zone.');
|
||||
const [box] = boxZone.splice(boxZone.findIndex(card => card.id === BOX), 1);
|
||||
player(game, owner.id).hand.push(box);
|
||||
const target = game.deck.shift(), other = game.deck.shift();
|
||||
assert.ok(target && other, 'The original ordinary deck must supply two discard targets.');
|
||||
game.discard.push(target, other);
|
||||
assert.deepEqual(inventory(game), source.originalInventory);
|
||||
|
||||
return { game, source, owner: owner.id, donor: donor.id, bidder, target: target.id,
|
||||
lot: structuredClone(game.auction), aid: structuredClone(game.aid),
|
||||
ownerSpice: player(game, owner.id).spice, originalCards: inventory(game) };
|
||||
}
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Semuta ${claim ? 'claims' : 'passes'} the paid Box during a standing Basic ordinary bid`, () => {
|
||||
const f = fixture();
|
||||
const paid = applyAction(f.game, f.owner, { type: 'card', card: BOX });
|
||||
assert.equal(player(paid, f.owner).spice, f.ownerSpice - 2);
|
||||
assert.ok(paid.pendingNullentropy);
|
||||
assert.deepEqual(paid.auction, f.lot);
|
||||
assert.deepEqual(paid.aid, f.aid);
|
||||
const searchEvent = paid.pendingNullentropy.event;
|
||||
const pending = applyAction(paid, f.owner, { type: 'decision', event: searchEvent, card: f.target });
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'nullentropyDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(entry =>
|
||||
[entry.discardedBy, entry.card.id, entry.publicFace]), [[f.owner, BOX, true]]);
|
||||
assert.deepEqual(pending.auction, f.lot);
|
||||
assert.deepEqual(pending.aid, f.aid);
|
||||
assert.equal(player(pending, f.owner).spice, f.ownerSpice - 2);
|
||||
assert.equal(player(pending, f.owner).hand.filter(card => card.id === f.target).length, 1);
|
||||
assert.equal(viewGame(pending, f.source.holder).players.find(row => row.id === f.owner)?.hand, undefined);
|
||||
assert.deepEqual(viewGame(pending, f.source.holder).semutaReaction?.candidates, []);
|
||||
const shuffled = pending.discard.filter(card => card.id !== BOX).map(card => card.id);
|
||||
rejectWithoutMutation(pending, f.source.holder, { type: 'semutaCommit', event: 'stale-event' });
|
||||
rejectWithoutMutation(pending, pending.auction!.active, { type: 'passBid' });
|
||||
|
||||
const alteredLot = reload(pending);
|
||||
alteredLot.auction!.bid += 1;
|
||||
rejectWithoutMutation(alteredLot, f.source.holder, { type: 'semutaCommit', event });
|
||||
const alteredAid = reload(pending);
|
||||
alteredAid.aid[f.donor]!.amount += 1;
|
||||
rejectWithoutMutation(alteredAid, f.source.holder, { type: 'semutaCommit', event });
|
||||
|
||||
let resumed = reload(pending);
|
||||
if (claim) resumed = applyAction(resumed, f.source.holder, { type: 'semutaCommit', event });
|
||||
else for (const row of resumed.players)
|
||||
resumed = applyAction(resumed, row.id, { type: 'semutaPass', event });
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(resumed.auction, f.lot);
|
||||
assert.deepEqual(resumed.aid, f.aid);
|
||||
assert.equal(player(resumed, f.owner).spice, f.ownerSpice - 2);
|
||||
assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.target).length, 1);
|
||||
assert.equal(player(resumed, f.source.holder).hand.some(card => card.id === BOX), claim);
|
||||
assert.equal(resumed.discard.some(card => card.id === BOX), !claim);
|
||||
assert.equal(resumed.discard.some(card => card.id === f.source.semuta), claim);
|
||||
assert.deepEqual(resumed.discard.filter(card => card.id !== BOX && card.id !== f.source.semuta)
|
||||
.map(card => card.id), shuffled);
|
||||
assert.deepEqual(inventory(resumed), f.originalCards);
|
||||
rejectWithoutMutation(resumed, f.source.holder, { type: 'semutaCommit', event });
|
||||
rejectWithoutMutation(resumed, f.owner, { type: 'decision', event: searchEvent, card: f.target });
|
||||
const active = resumed.auction!.active;
|
||||
const continued = applyAction(resumed, active, { type: 'passBid' });
|
||||
assert.notDeepEqual(continued.auction, f.lot, 'The same original auction continues after the reaction.');
|
||||
assert.equal(player(continued, f.owner).spice, f.ownerSpice - 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
advanceSemutaRicheseCacheKarama, createSemutaRicheseCacheKaramaFixture,
|
||||
semutaRicheseCacheKaramaInventory as inventory, semutaRicheseCacheKaramaPlayer as player,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
||||
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.truthtrance;
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
|
||||
/** A native Basic roster really buys Semuta in turn one and progresses through
|
||||
* the next Richese declaration. Only the Box and original search candidates
|
||||
* are staged by conserved card moves; lot, bids, wallets and clocks are real. */
|
||||
function fixture(method: 'onceAround' | 'silent') {
|
||||
const native = createSemutaRicheseCacheKaramaFixture();
|
||||
let game = structuredClone(native.beforeDeclaration);
|
||||
const box = 'richese-nullentropy-box', owner = native.richese, holder = native.holder;
|
||||
assert.equal(player(game, holder).hand.some(card => card.id === native.semuta), true);
|
||||
moveSemutaEmperorCard(game, box, owner);
|
||||
assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner)));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const index = game.deck.findIndex(card => card.effect !== 'nullentropyBox');
|
||||
assert.ok(index >= 0);
|
||||
game.discard.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
const physical = inventory(game);
|
||||
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, position: 'first' });
|
||||
game = advanceSemutaRicheseCacheKarama(game, state => state.decision?.kind === 'richeseCache' && !state.response && !state.phaseOpening);
|
||||
const lotCard = game.richeseCache![0]; assert.ok(lotCard && lotCard.id !== box);
|
||||
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: lotCard.id, method, ...(method === 'onceAround' ? { direction: 'clockwise' } : {}) });
|
||||
game = advanceSemutaRicheseCacheKarama(game, state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
const lot = game.richeseAuction!;
|
||||
assert.equal(lot.source, 'cache'); assert.equal(lot.method, method);
|
||||
const bidder = method === 'onceAround' ? lot.active! : lot.eligible[0];
|
||||
assert.ok(bidder);
|
||||
game = applyAction(game, bidder, { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 });
|
||||
assert.ok(clean(game) && game.richeseAuction && !game.richeseAuction.outcome);
|
||||
assert.ok(player(game, owner).spice >= 2);
|
||||
return { game, owner, holder, box, physical, lotCard: lotCard.id, bidder };
|
||||
}
|
||||
|
||||
function rejectWithoutMutation(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const method of ['onceAround', 'silent'] as const) for (const claim of [false, true]) {
|
||||
void test(`Semuta ${claim ? 'claims' : 'passes'} a completed Box during a Basic Richese ${method} lot`, () => {
|
||||
const f = fixture(method), before = structuredClone(f.game);
|
||||
const balances = Object.fromEntries(before.players.map(p => [p.id, p.spice]));
|
||||
const openingDiscard = before.discard.map(card => card.id);
|
||||
assert.ok(openingDiscard.length >= 2);
|
||||
const paid = applyAction(before, f.owner, { type: 'card', card: f.box });
|
||||
assert.equal(player(paid, f.owner).spice, balances[f.owner] - 2);
|
||||
assert.ok(paid.pendingNullentropy);
|
||||
assert.deepEqual(paid.richeseAuction, before.richeseAuction);
|
||||
assert.deepEqual(paid.richeseFunding, before.richeseFunding);
|
||||
const target = paid.discard[0].id, other = paid.discard[1].id;
|
||||
rejectWithoutMutation(paid, f.holder, { type: 'decision', event: paid.pendingNullentropy.event, card: target });
|
||||
const pending = applyAction(paid, f.owner, { type: 'decision', event: paid.pendingNullentropy.event, card: target });
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'nullentropyDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.richeseAuction, before.richeseAuction);
|
||||
assert.deepEqual(pending.richeseFunding, before.richeseFunding);
|
||||
assert.equal(player(pending, f.owner).hand.filter(card => card.id === target).length, 1);
|
||||
assert.equal(player(pending, f.owner).hand.some(card => card.id === other), false);
|
||||
assert.equal(pending.discard.filter(card => card.id === f.box).length, 1);
|
||||
assert.deepEqual(viewGame(pending, f.holder).semutaReaction?.candidates, []);
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(pending, f.owner).semutaReaction?.canCommit, false);
|
||||
assert.deepEqual(inventory(pending), f.physical);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
rejectWithoutMutation(pending, f.holder, { type: 'semutaCommit', event: 'stale' });
|
||||
let done = reload(pending);
|
||||
if (claim) done = applyAction(done, f.holder, { type: 'semutaCommit', event });
|
||||
else for (const seat of done.players) done = applyAction(done, seat.id, { type: 'semutaPass', event });
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(done.richeseAuction, before.richeseAuction);
|
||||
assert.deepEqual(done.richeseFunding, before.richeseFunding);
|
||||
assert.equal(player(done, f.owner).spice, balances[f.owner] - 2);
|
||||
assert.equal(player(done, f.owner).hand.filter(card => card.id === target).length, 1);
|
||||
assert.equal(player(done, f.holder).hand.some(card => card.id === f.box), claim);
|
||||
assert.equal(done.discard.some(card => card.id === f.box), !claim);
|
||||
assert.deepEqual(inventory(done), f.physical);
|
||||
const originalLot = done.richeseAuction!, originalEvent = originalLot.event;
|
||||
for (let count = 0; done.richeseAuction?.event === originalEvent && !done.richeseAuction.outcome && count < 12; count++) {
|
||||
const lot = done.richeseAuction;
|
||||
const actor = method === 'onceAround' ? lot.active : lot.eligible.find(id => lot.sealed[id] === undefined);
|
||||
assert.ok(actor, 'The original finite lot has an unacted bidder');
|
||||
done = applyAction(done, actor, { type: 'richeseBid', event: originalEvent,
|
||||
amount: method === 'onceAround' ? null : 0, allyPayment: 0 });
|
||||
}
|
||||
assert.ok(!done.richeseAuction || done.richeseAuction.event !== originalEvent || done.richeseAuction.outcome,
|
||||
'Original bidding reaches its native settlement without replaying the Box');
|
||||
assert.equal(player(done, f.owner).hand.some(card => card.id === target), true);
|
||||
assert.deepEqual(inventory(done), f.physical);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user