Prototype Emperor Nexus bank-auction purchase

This commit is contained in:
Quintine committed 2026-09-28 13:38:49 +10:00
1 parent 23ce406872
commit 1bda2ec31e
18 files changed
+916 -49

No files matched your search

+1 -1
View File
@@ -170,7 +170,7 @@ export function nexusInventory(g: Game): void {
[...NEXUS_FACTIONS].sort(),
);
assert.deepEqual(
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
[...g.deck, ...g.discard, ...(g.auction?.cards.slice(g.auction.index) ?? []), ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort(),
baseDeck()
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { applyAction, type Game } from '../game/engine';
import { finishNexusSpice, nexusReady, nexusTurnTwo, orderNexusSpice } from './fixture-nexus-cards';
/** Real Nexus draw, alliance formation, normal Bidding and sealed winner payment. */
export function wonEmperorNexusBankAuction(advanced: boolean, ids: [string, string, string] = ['p', 'q', 'r']): Game {
const [fremen, owner, harkonnen] = ids;
let g = nexusTurnTwo({ advanced, seatIds: ids });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = nexusReady(nexusReady(g));
g = applyAction(g, fremen, { type: 'alliance', target: harkonnen });
g = applyAction(g, harkonnen, { type: 'alliance', target: fremen });
g = finishNexusSpice(g);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
const cards = g.nexusCards!.cards!;
cards.deck = ['emperor', ...cards.deck.filter(card => card !== 'emperor')];
g = applyAction(g, owner, {
type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
});
for (const id of g.nexusCards!.phase!.eligible)
if (!g.nexusCards!.phase!.done.includes(id))
g = applyAction(g, id, {
type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'keep', ownRedraws: 0,
});
assert.equal(g.phase, 2);
g = nexusReady(g);
for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 30; i++) {
if (g.phaseOpening)
g = applyAction(g, g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id, { type: 'ready' });
else if (g.auction?.active === owner && !g.auction.bid)
g = applyAction(g, owner, { type: 'bid', amount: 2 });
else if (g.auction)
g = applyAction(g, g.auction.active, { type: 'passBid' });
else g = nexusReady(g);
}
assert.equal(g.phase, 3);
assert.deepEqual(g.decision, { kind: 'auctionPayment', player: owner });
assert.equal(g.auction?.bidder, owner);
assert.equal(g.auction?.bid, 2);
assert.equal(g.auction?.allyPayment ?? 0, 0);
return g;
}
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { nexusInventory, nexusReload } from './fixture-nexus-cards';
import { wonEmperorNexusBankAuction } from './fixture-nexus-emperor-purchase';
const owner = 'q';
function reject(g: Game, player: string, action: Action, reason: RegExp) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, player, action), reason);
assert.equal(JSON.stringify(g), before);
}
void test('real Basic/Advanced normal auction purchase keeps exactly the personally affordable bid and spends the physical Nexus card', () => {
for (const advanced of [false, true]) {
const g = wonEmperorNexusBankAuction(advanced);
const price = g.auction!.bid;
const lot = g.auction!.cards[g.auction!.index];
const before = JSON.stringify(g);
const ownerView = viewGame(g, owner);
assert.deepEqual(ownerView.nexusEmperorSecretAlly!.purchase, { blocked: null, price });
for (const id of ['p', 'r']) {
assert.equal(viewGame(g, id).nexusEmperorSecretAlly, null);
assert.equal(JSON.stringify(viewGame(g, id)).includes('nexusEmperorPurchaseHistory'), false);
}
const action = { type: 'nexusEmperorPurchase', event: ownerView.nexusEmperorSecretAlly!.event };
const done = applyAction(g, owner, action);
assert.equal(JSON.stringify(g), before);
nexusInventory(done);
assert.equal(done.players.find(p => p.id === owner)!.spice, g.players.find(p => p.id === owner)!.spice);
assert.equal(done.players.find(p => p.id === owner)!.hand.filter(card => card.id === lot.id).length, 1);
assert.equal(done.nexusCards!.cards!.hands[owner], null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.deepEqual(done.nexusEmperorPurchaseEvents, [action.event]);
assert.equal(done.nexusEmperorPurchaseHistory?.[0].card, lot.id);
assert.equal(done.nexusEmperorPurchaseHistory?.[0].price, price);
assert.equal(done.nexusEmperorPurchaseHistory?.[0].afterSpice, done.nexusEmperorPurchaseHistory?.[0].beforeSpice);
assert.equal(done.log.filter(entry => entry.text.includes('spent Emperor Nexus Secret Ally after proving')).length, 1);
for (const p of done.players)
assert.deepEqual(viewGame(nexusReload(done), p.id), viewGame(done, p.id));
reject(done, owner, action, /Emperor Nexus|current|payment|card/i);
}
});
void test('every eligible winner confirms payment regardless of concealed Emperor card ownership', () => {
const withCard = wonEmperorNexusBankAuction(false);
const without = nexusReload(withCard);
without.nexusCards!.cards!.hands[owner] = null;
without.nexusCards!.cards!.deck.push('emperor');
const waitingWith = normalizeAutomaticGame(withCard);
const waitingWithout = normalizeAutomaticGame(without);
assert.deepEqual(waitingWith.decision, { kind: 'auctionPayment', player: owner });
assert.deepEqual(waitingWithout.decision, waitingWith.decision);
assert.equal(viewGame(waitingWithout, owner).nexusEmperorSecretAlly, null);
const paid = applyAction(waitingWithout, owner, { type: 'decision', karama: false });
assert.equal(paid.players.find(p => p.id === owner)!.spice,
without.players.find(p => p.id === owner)!.spice - without.auction!.bid);
assert.equal(paid.players.find(p => p.id === owner)!.hand.filter(
card => card.id === without.auction!.cards[without.auction!.index].id).length, 1);
nexusInventory(paid);
});
void test('a prior normal purchase cannot authorize spending Emperor Nexus on a later lot or revised bid', () => {
let g = wonEmperorNexusBankAuction(false);
const first = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
const revised = nexusReload(g);
revised.auction!.bid++;
assert.notEqual(viewGame(revised, owner).nexusEmperorSecretAlly!.event, first.event);
reject(revised, owner, first, /current Emperor Nexus/i);
g = applyAction(g, owner, { type: 'decision', karama: false });
for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 20; i++) {
if (g.auction?.active === owner && !g.auction.bid)
g = applyAction(g, owner, { type: 'bid', amount: 2 });
else if (g.auction)
g = applyAction(g, g.auction.active, { type: 'passBid' });
}
assert.equal(g.auction?.index, 1);
assert.equal(g.decision?.player, owner);
assert.notEqual(viewGame(g, owner).nexusEmperorSecretAlly!.event, first.event);
reject(g, owner, first, /current Emperor Nexus/i);
const second = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
const done = applyAction(g, owner, second);
assert.equal(done.nexusEmperorPurchaseHistory?.[0].auctionIndex, 1);
});
void test('invalid normal-lot custody and full winning hand cannot spend the physical Nexus card', () => {
const g = wonEmperorNexusBankAuction(false);
const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
const duplicate = nexusReload(g);
duplicate.deck.push(structuredClone(g.auction!.cards[g.auction!.index]));
assert.match(viewGame(duplicate, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /conflicting physical custody/);
reject(duplicate, owner, action, /conflicting physical custody/);
const full = nexusReload(g);
full.players.find(p => p.id === owner)!.hand.push(...full.deck.splice(0, 4));
assert.match(viewGame(full, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /room for the committed/);
reject(full, owner, action, /room for the committed/);
});
void test('stale, foreign, malformed, underfunded and ally-funded purchase attempts reject before card or auction mutation', () => {
const g = wonEmperorNexusBankAuction(false);
const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
reject(g, 'p', action, /Emperor Nexus|current|payment/i);
reject(g, owner, { ...action, event: 'stale' }, /current|payment/i);
reject(g, owner, { ...action, price: 0 }, /current|payment/i);
const underfunded = nexusReload(g);
underfunded.players.find(p => p.id === owner)!.spice = g.auction!.bid - 1;
assert.match(viewGame(underfunded, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /own spice/);
reject(underfunded, owner, action, /own spice/);
const allyFunded = nexusReload(g);
allyFunded.auction!.allyPayment = 1;
assert.match(viewGame(allyFunded, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /own spice/);
reject(allyFunded, owner, action, /own spice/);
});
void test('signed historical purchase rejects a changed price, lot, proof or missing independent-use marker after JSON restore', () => {
const g = wonEmperorNexusBankAuction(true);
const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
const done = applyAction(g, owner, action);
for (const tamper of [
(copy: Game) => { copy.nexusEmperorPurchaseHistory![0].price++; },
(copy: Game) => { copy.nexusEmperorPurchaseHistory![0].card = 'forged-lot'; },
(copy: Game) => { copy.nexusEmperorPurchaseHistory![0].afterSpice--; },
(copy: Game) => { delete copy.nexusEmperorPurchaseEvents; },
(copy: Game) => { copy.nexusEmperorPurchaseEvents!.push(action.event); },
]) {
const copy = nexusReload(done);
tamper(copy);
const before = JSON.stringify(copy);
assert.throws(() => viewGame(copy, owner), /Emperor Nexus purchase/);
assert.equal(JSON.stringify(copy), before);
}
});
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
EmperorNexusPurchaseError,
quoteEmperorNexusPurchase,
emperorNexusPurchaseEvent,
signEmperorNexusPurchase,
validateEmperorNexusPurchase,
type EmperorNexusPurchase,
} from '../game/nexus-emperor-purchase';
import { emperorNexusEvent } from '../game/nexus-emperor-secret-ally';
const context = {
turn: 4,
advanced: false,
players: [
{ id: 'winner', faction: 'atreides' as const },
{ id: 'rival', faction: 'guild' as const },
],
};
function purchase(): EmperorNexusPurchase {
const receipt: EmperorNexusPurchase = {
kind: 'purchase',
event: emperorNexusPurchaseEvent(3, 'winner', 2, 1, 4),
owner: 'winner',
turn: 3,
phase: 3,
sequence: 2,
faction: 'atreides',
advanced: false,
price: 4,
auctionIndex: 1,
card: 'treachery-17',
beforeSpice: 7,
afterSpice: 7,
signature: '',
};
receipt.signature = signEmperorNexusPurchase(receipt);
return receipt;
}
void test('only a positive whole bid affordable personally and without ally contribution can be quoted', () => {
assert.equal(quoteEmperorNexusPurchase(1, 1, 0), undefined);
assert.equal(quoteEmperorNexusPurchase(5, 9, 0), undefined);
assert.equal(quoteEmperorNexusPurchase(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 0), undefined);
for (const [price, ownSpice, allyPayment] of [
[0, 10, 0], [-1, 10, 0], [1.5, 10, 0], [NaN, 10, 0],
[Infinity, 10, 0], [Number.MAX_SAFE_INTEGER + 1, Number.MAX_SAFE_INTEGER + 1, 0],
[5, 4, 0], [5, 4.5, 0], [5, -1, 0], [5, Infinity, 0],
[5, 10, 1], [5, 10, -1], [5, 10, NaN],
]) {
assert.throws(
() => quoteEmperorNexusPurchase(price, ownSpice, allyPayment),
EmperorNexusPurchaseError,
`price=${price} own=${ownSpice} ally=${allyPayment}`,
);
}
});
void test('typed signatures are stable across property order and JSON reload; earlier turns remain valid', () => {
const receipt = purchase();
assert.equal(receipt.signature, signEmperorNexusPurchase({ ...receipt, signature: 'ignored' }));
assert.equal(receipt.signature, signEmperorNexusPurchase({
signature: '', afterSpice: 7, beforeSpice: 7, card: 'treachery-17', price: 4, auctionIndex: 1,
advanced: false, faction: 'atreides', sequence: 2, phase: 3,
turn: 3, owner: 'winner', event: receipt.event, kind: 'purchase',
}));
assert.doesNotThrow(() => validateEmperorNexusPurchase(context, JSON.parse(JSON.stringify(receipt))));
assert.doesNotThrow(() => validateEmperorNexusPurchase({ ...context, advanced: true }, {
...receipt, advanced: true, signature: signEmperorNexusPurchase({ ...receipt, advanced: true }),
}));
const minimum = { ...receipt, price: 1, event: emperorNexusPurchaseEvent(3, 'winner', 2, 1, 1), beforeSpice: 1, afterSpice: 1 };
minimum.signature = signEmperorNexusPurchase(minimum);
assert.doesNotThrow(() => validateEmperorNexusPurchase(context, minimum));
});
void test('receipt fields and financial transitions cannot be changed after signing', () => {
const receipt = purchase();
const invalid = (patch: Partial<EmperorNexusPurchase>) =>
assert.throws(
() => validateEmperorNexusPurchase(context, { ...receipt, ...patch }),
EmperorNexusPurchaseError,
);
invalid({ kind: 'revival' as never });
invalid({ owner: 'rival' });
invalid({ faction: 'guild' });
invalid({ advanced: true });
invalid({ turn: 4 });
invalid({ phase: 4 as never });
invalid({ sequence: 3 });
invalid({ event: emperorNexusEvent(3, 4, 'winner', 2) });
invalid({ price: 5 });
invalid({ auctionIndex: 2 });
invalid({ card: 'treachery-18' });
invalid({ beforeSpice: 8 });
invalid({ afterSpice: 6 });
invalid({ signature: 'forged' });
});
void test('even re-signed invalid receipts reject future, nonauction, malformed, or non-reimbursed records', () => {
const receipt = purchase();
const invalid = (patch: Partial<EmperorNexusPurchase>) => {
const changed = { ...receipt, ...patch };
changed.signature = signEmperorNexusPurchase(changed);
assert.throws(() => validateEmperorNexusPurchase(context, changed), EmperorNexusPurchaseError);
};
invalid({ turn: 5, event: emperorNexusEvent(5, 3, 'winner', 2) });
invalid({ turn: 0, event: emperorNexusEvent(0, 3, 'winner', 2) });
invalid({ phase: 4 as never, event: emperorNexusEvent(3, 4, 'winner', 2) });
invalid({ sequence: -1, event: emperorNexusEvent(3, 3, 'winner', -1) });
invalid({ sequence: Number.MAX_SAFE_INTEGER + 1, event: emperorNexusEvent(3, 3, 'winner', Number.MAX_SAFE_INTEGER + 1) });
invalid({ price: 0 });
invalid({ auctionIndex: -1 });
invalid({ price: 8 });
invalid({ price: 1.5 });
invalid({ beforeSpice: 7.5, afterSpice: 7.5 });
invalid({ afterSpice: 6 });
invalid({ afterSpice: 11 });
invalid({ card: ' ' });
invalid({ beforeSpice: Number.MAX_SAFE_INTEGER + 1, afterSpice: Number.MAX_SAFE_INTEGER + 1 });
invalid({ owner: ' ', event: emperorNexusEvent(3, 3, ' ', 2) });
invalid({ owner: 'stranger', event: emperorNexusEvent(3, 3, 'stranger', 2) });
invalid({ faction: 'guild' });
invalid({ advanced: true });
invalid({ extra: 'hidden' } as never);
invalid({ price: undefined } as never);
assert.throws(
() => validateEmperorNexusPurchase(context, null as never),
EmperorNexusPurchaseError,
);
assert.throws(
() => validateEmperorNexusPurchase({ ...context, turn: 2 }, receipt),
EmperorNexusPurchaseError,
);
assert.throws(
() => validateEmperorNexusPurchase({ ...context, players: context.players.slice(1) }, receipt),
EmperorNexusPurchaseError,
);
assert.throws(
() => validateEmperorNexusPurchase({ ...context, players: [context.players[0], context.players[0]] }, receipt),
EmperorNexusPurchaseError,
);
});
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { unitStore } from './fixture-nexus-room-store';
import { viewGame, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { wonEmperorNexusBankAuction } from './fixture-nexus-emperor-purchase';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const rows = (store: ReturnType<typeof unitStore>) =>
store.sqlite.prepare('SELECT state,version FROM rooms ORDER BY code').all();
async function fixture(t: test.TestContext, advanced: boolean) {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Emperor purchase recovery', 'fremen', advanced, []);
const code = made.view.code;
const tokens = [made.token];
for (const faction of ['atreides', 'harkonnen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string, string, string];
const g = wonEmperorNexusBankAuction(advanced, ids);
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
return { ...store, code, tokens, auths, ids, g };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
async function restored(f: Fixture, expected: Game) {
const before = rows(f);
for (const [i, token] of f.tokens.entries()) {
const rooms = f.restart();
const auth = await rooms.authenticate(f.code, token);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(expected, f.ids[i]));
assert.equal('nexusEmperorPurchaseHistory' in view, false);
assert.equal('nexusEmperorPurchaseEvents' in view, false);
if (i !== 1) assert.equal(view.nexusEmperorSecretAlly, null);
for (const rival of view.players.filter(player => player.id !== f.ids[i])) {
assert.equal('hand' in rival, false);
assert.equal('spice' in rival, false);
}
}
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(rows(f), before);
}
void test('personally funded purchase survives SQLite restart without losing card or charging bid; stale replay does not commit', async t => {
for (const advanced of [false, true]) {
const f = await fixture(t, advanced);
await restored(f, f.g);
const initial = rows(f);
const seats = f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all();
const offer = viewGame(f.g, f.ids[1]).nexusEmperorSecretAlly!;
const action = { type: 'nexusEmperorPurchase', event: offer.event };
for (const [i, malformed] of [[0, action], [1, { ...action, event: 'stale' }], [1, { ...action, price: 0 }]] as const)
await assert.rejects(f.restart().act(f.code, f.auths[i], f.g.version, malformed, clock));
assert.deepEqual(rows(f), initial);
const lot = f.g.auction!.cards[f.g.auction!.index].id;
await f.restart().act(f.code, f.auths[1], f.g.version, action, clock);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, f.g.version + 1);
assert.equal(done.players[1].spice, f.g.players[1].spice);
assert.equal(done.players[1].hand.filter(card => card.id === lot).length, 1);
assert.equal(done.nexusEmperorPurchaseHistory?.length, 1);
assert.deepEqual(done.nexusEmperorPurchaseEvents, [offer.event]);
assert.equal(done.nexusCards!.cards!.hands[f.ids[1]], null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
await restored(f, done);
const saved = rows(f);
await assert.rejects(f.restart().act(f.code, f.auths[1], f.g.version, action, clock), /table changed/);
await assert.rejects(f.restart().act(f.code, f.auths[1], done.version, action, clock));
assert.deepEqual(rows(f), saved);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
assert.deepEqual(f.writes.map(write => write.changes), [1]);
}
});
void test('racing Emperor purchase and ordinary auction payment preserve one winner and one physical lot', async t => {
const f = await fixture(t, true);
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
const timer = setTimeout(release, 2000);
const event = viewGame(f.g, f.ids[1]).nexusEmperorSecretAlly!.event;
let outcomes: PromiseSettledResult<unknown>[];
try {
outcomes = await Promise.allSettled([
f.restart().act(f.code, f.auths[1], f.g.version, { type: 'nexusEmperorPurchase', event }, clock),
f.restart().act(f.code, f.auths[1], f.g.version, { type: 'decision', karama: false }, clock),
]);
} finally {
clearTimeout(timer);
delete f.hooks.beforeWrite;
}
assert.equal(arrivals, 2);
assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1);
const done = await f.restart().readRoom(f.code);
const lot = f.g.auction!.cards[f.g.auction!.index].id;
assert.equal(done.version, f.g.version + 1);
assert.equal(done.players[1].hand.filter(card => card.id === lot).length, 1);
assert.equal(done.nexusEmperorPurchaseHistory?.length ?? 0, done.players[1].spice === f.g.players[1].spice ? 1 : 0);
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
await restored(f, done);
});
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { NexusEmperorSecretAlly } from '../components/nexus-emperor-secret-ally';
import { botActions } from '../game/bots';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { nexusEmperorPurchaseAction } from '../game/nexus-emperor-secret-ally-options';
import { wonEmperorNexusBankAuction } from './fixture-nexus-emperor-purchase';
const owner = 'q';
const html = (view: GameView, busy = false) =>
renderToStaticMarkup(createElement(NexusEmperorSecretAlly, { game: view, act() {}, busy }));
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced' : 'Basic'} owner sees a private bank-paid choice and every bot profile can keep its own funded bid`, () => {
const g = wonEmperorNexusBankAuction(advanced);
const v = viewGame(g, owner);
assert.equal(v.nexusEmperorSecretAlly?.purchase?.blocked, null);
assert.equal(v.nexusEmperorSecretAlly?.purchase?.price, 2);
assert.deepEqual(nexusEmperorPurchaseAction(v), {
type: 'nexusEmperorPurchase', event: v.nexusEmperorSecretAlly!.event,
});
const text = html(v);
assert.match(text, /final bid is 2 spice/);
assert.match(text, /all 2 spice.*own supply without ally aid/);
assert.match(text, /keep that spice instead of paying the bank/);
assert.doesNotMatch(text, /disabled=""/);
assert.match(html(v, true), /disabled=""/);
assert.equal(text.includes(g.auction!.cards[g.auction!.index].name), false);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const ownView = viewGame(g, owner);
ownView.players.find((player) => player.id === owner)!.bot = difficulty;
const [action] = botActions(ownView);
assert.deepEqual(action, nexusEmperorPurchaseAction(ownView));
const after = applyAction(g, owner, action);
assert.equal(after.players.find((player) => player.id === owner)!.spice, g.players.find((player) => player.id === owner)!.spice);
assert.ok(after.players.find((player) => player.id === owner)!.hand.some((card) => card.id === g.auction!.cards[g.auction!.index].id));
assert.equal(after.nexusCards!.cards!.hands[owner], null);
}
});
void test('blocked, underfunded, interrupted and foreign owners have no purchase control or bot candidate', () => {
const g = wonEmperorNexusBankAuction(false);
for (const id of ['p', 'r']) {
const v = viewGame(g, id);
assert.equal(v.nexusEmperorSecretAlly, null);
assert.equal(nexusEmperorPurchaseAction(v), null);
assert.equal(html(v), '');
assert.ok(!botActions(v).some((action) => action.type === 'nexusEmperorPurchase'));
}
const owned = viewGame(g, owner);
const blocked = structuredClone(owned);
blocked.nexusEmperorSecretAlly!.purchase!.blocked = 'Finish this payment first.';
assert.equal(nexusEmperorPurchaseAction(blocked), null);
assert.match(html(blocked), /Finish this payment first/);
assert.match(html(blocked), /disabled=""/);
const underfunded = structuredClone(owned);
underfunded.players.find((player) => player.id === owner)!.spice = 1;
assert.equal(nexusEmperorPurchaseAction(underfunded), null);
assert.match(html(underfunded), /disabled=""/);
assert.match(html(underfunded), /need 2 spice in your own supply/);
const interrupted = structuredClone(owned);
interrupted.automaticContinuationPending = true;
assert.equal(nexusEmperorPurchaseAction(interrupted), null);
assert.match(html(interrupted), /disabled=""/);
const foreignDecision = structuredClone(owned);
foreignDecision.decision = { kind: 'auctionPayment', player: 'r' };
assert.equal(nexusEmperorPurchaseAction(foreignDecision), null);
assert.equal(html(foreignDecision), '');
});