Prototype Emperor Nexus bank-auction purchase
This commit is contained in:
1 parent
23ce406872
commit
1bda2ec31e
18 files changed
+916
-49
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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), '');
|
||||
});
|
||||
Reference in new issue
Block a user