420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { runInNewContext } from 'node:vm';
|
|
import { webcrypto } from 'node:crypto';
|
|
import ts from 'typescript';
|
|
import {
|
|
quoteRicheseSettlement,
|
|
requireRicheseDeclarationCache,
|
|
RicheseSettlementError,
|
|
type RicheseSettlementInput,
|
|
} from '../game/richese-settlement';
|
|
import {
|
|
createRicheseAuction,
|
|
submitRicheseBid,
|
|
type RicheseAuctionSource,
|
|
} from '../game/richese-auction';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { baseDeck } from '../game/cards';
|
|
import { createGame, newPlayer, handLimit, type Game } from '../game/engine';
|
|
|
|
function input(source: RicheseAuctionSource = 'cache'): RicheseSettlementInput {
|
|
const card = richeseCards()[0];
|
|
let lot = createRicheseAuction({
|
|
event: 'richese-lot-2',
|
|
cardId: card.id,
|
|
owner: 'r',
|
|
source,
|
|
method: 'silent',
|
|
eligible: ['r', 'w', 'a'],
|
|
order: ['r', 'w', 'a'],
|
|
tieOrder: ['r', 'w', 'a'],
|
|
});
|
|
for (const [actor, amount] of [
|
|
['r', 0],
|
|
['w', 6],
|
|
['a', 0],
|
|
] as const)
|
|
lot = submitRicheseBid(lot, { event: lot.event, actor, amount }, 10);
|
|
return {
|
|
lot,
|
|
owner: { id: 'r', handCount: 1, handLimit: 4 },
|
|
winner: { id: 'w', handCount: 2, handLimit: 4, spice: 4 },
|
|
card,
|
|
funding: { amount: 6, allyPayment: 2, donor: 'a' },
|
|
allyCredit: { amount: 2 },
|
|
};
|
|
}
|
|
function unbid(source: RicheseAuctionSource): RicheseSettlementInput {
|
|
const value = input(source);
|
|
value.lot = { ...value.lot, outcome: { kind: 'unbid' } };
|
|
value.card = null;
|
|
value.winner = null;
|
|
value.funding = null;
|
|
value.allyCredit = null;
|
|
return value;
|
|
}
|
|
function reject(value: RicheseSettlementInput, pattern?: RegExp) {
|
|
const before = structuredClone(value);
|
|
assert.throws(
|
|
() => quoteRicheseSettlement(value),
|
|
pattern ?? RicheseSettlementError,
|
|
);
|
|
assert.deepEqual(value, before);
|
|
}
|
|
|
|
void test('sold cache and Black Market quotes retain exact declared payer splits and detached physical cards', () => {
|
|
for (const source of ['cache', 'blackMarket'] as const) {
|
|
const value = input(source),
|
|
before = structuredClone(value);
|
|
const quote = quoteRicheseSettlement(value);
|
|
assert.deepEqual(quote, {
|
|
kind: 'sold',
|
|
card: value.card,
|
|
winner: 'w',
|
|
amount: 6,
|
|
ownPayment: 4,
|
|
allyPayment: 2,
|
|
});
|
|
assert.deepEqual(value, before);
|
|
assert.equal(quote.kind, 'sold');
|
|
if (quote.kind !== 'sold') throw Error('Missing sold quote');
|
|
quote.card.name = 'Detached test copy';
|
|
assert.deepEqual(value, before);
|
|
}
|
|
});
|
|
|
|
void test('every affordable exact split is accepted without silently substituting available ally funds', () => {
|
|
for (let amount = 0; amount <= 8; amount++)
|
|
for (let allyPayment = 0; allyPayment <= amount; allyPayment++)
|
|
for (const extra of [0, 4]) {
|
|
const value = input();
|
|
value.lot = {
|
|
...value.lot,
|
|
outcome: { kind: 'sold', winner: 'w', amount },
|
|
};
|
|
value.winner = {
|
|
...value.winner!,
|
|
spice: amount - allyPayment + extra,
|
|
};
|
|
value.funding = {
|
|
amount,
|
|
allyPayment,
|
|
donor: allyPayment ? 'a' : null,
|
|
};
|
|
value.allyCredit = allyPayment ? { amount: allyPayment + extra } : null;
|
|
const result = quoteRicheseSettlement(value);
|
|
assert.equal(result.kind, 'sold');
|
|
if (result.kind !== 'sold') throw Error('Missing quote');
|
|
assert.equal(result.ownPayment, amount - allyPayment);
|
|
assert.equal(result.allyPayment, allyPayment);
|
|
// Even enough total funds do not authorize moving a declared share.
|
|
if (allyPayment > 0) {
|
|
value.allyCredit = { amount: allyPayment - 1 };
|
|
reject(value, /no longer funded/);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('source-specific unbid branches preserve retain, future choice and automatic removal', () => {
|
|
const blackMarket = unbid('blackMarket');
|
|
blackMarket.owner = { id: 'r', handCount: 8, handLimit: 4 };
|
|
assert.deepEqual(quoteRicheseSettlement(blackMarket), {
|
|
kind: 'retainBlackMarket',
|
|
});
|
|
const cache = unbid('cache');
|
|
assert.deepEqual(quoteRicheseSettlement(cache), { kind: 'chooseUnbidCache' });
|
|
cache.owner = { ...cache.owner, handCount: 4 };
|
|
reject(cache, /reserved auction card/);
|
|
cache.card = richeseCards()[0];
|
|
const result = quoteRicheseSettlement(cache);
|
|
assert.deepEqual(result, { kind: 'removeCache', card: cache.card });
|
|
if (result.kind !== 'removeCache') throw Error('Missing removal quote');
|
|
result.card.id = 'detached';
|
|
assert.equal(cache.card.id, cache.lot.cardId);
|
|
});
|
|
|
|
void test('an open lot performs no settlement or future availability checks', () => {
|
|
const value = unbid('cache');
|
|
value.lot = { ...value.lot, outcome: null };
|
|
const before = structuredClone(value);
|
|
assert.deepEqual(quoteRicheseSettlement(value), { kind: 'open' });
|
|
assert.deepEqual(value, before);
|
|
});
|
|
|
|
|
|
void test('server-supplied faction hand limits preserve last-slot purchases and reject full winners', () => {
|
|
for (const limit of [4, 5, 8]) {
|
|
const value = input();
|
|
value.winner = { ...value.winner!, handLimit: limit, handCount: limit - 1 };
|
|
assert.equal(quoteRicheseSettlement(value).kind, 'sold');
|
|
value.winner = { ...value.winner!, handCount: limit };
|
|
reject(value, /winning hand has no room/);
|
|
}
|
|
});
|
|
|
|
void test('missing source card, seated identities and exact funding reject without mutation', () => {
|
|
const mutations: ((value: RicheseSettlementInput) => void)[] = [
|
|
(v) => {
|
|
v.owner = { ...v.owner, id: 'missing' };
|
|
},
|
|
(v) => {
|
|
v.winner = null;
|
|
},
|
|
(v) => {
|
|
v.winner = { ...v.winner!, id: 'r' };
|
|
},
|
|
(v) => {
|
|
v.card = null;
|
|
},
|
|
(v) => {
|
|
v.card = { ...v.card!, id: 'different-physical-id' };
|
|
},
|
|
(v) => {
|
|
v.funding = null;
|
|
},
|
|
(v) => {
|
|
v.funding = { ...v.funding!, amount: 5 };
|
|
},
|
|
(v) => {
|
|
v.allyCredit = null;
|
|
},
|
|
(v) => {
|
|
v.allyCredit = { amount: 1 };
|
|
},
|
|
(v) => {
|
|
v.winner = { ...v.winner!, spice: 3 };
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const value = input();
|
|
mutate(value);
|
|
reject(value);
|
|
}
|
|
});
|
|
|
|
void test('malformed saved counts and payment numbers fail with the module error class', () => {
|
|
for (const invalid of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
|
for (const field of ['handCount', 'handLimit', 'spice'] as const) {
|
|
const value = input();
|
|
value.winner = { ...value.winner!, [field]: invalid };
|
|
reject(value);
|
|
}
|
|
for (const field of ['amount', 'allyPayment'] as const) {
|
|
const value = input();
|
|
value.funding = { ...value.funding!, [field]: invalid };
|
|
if (field === 'amount')
|
|
value.lot = {
|
|
...value.lot,
|
|
outcome: { kind: 'sold', winner: 'w', amount: invalid },
|
|
};
|
|
reject(value);
|
|
}
|
|
const value = input();
|
|
value.allyCredit = { amount: invalid };
|
|
reject(value);
|
|
}
|
|
const over = input();
|
|
over.funding = { amount: 6, allyPayment: 7, donor: 'a' };
|
|
reject(over);
|
|
const emptyLimit = input();
|
|
emptyLimit.winner = { ...emptyLimit.winner!, handLimit: 0 };
|
|
reject(emptyLimit);
|
|
});
|
|
|
|
void test('Black Market declaration exhaustion is a separate pure immediate-continuation guard', () => {
|
|
const value = unbid('blackMarket'),
|
|
before = structuredClone(value);
|
|
assert.deepEqual(quoteRicheseSettlement(value), {
|
|
kind: 'retainBlackMarket',
|
|
});
|
|
// User ruling 7 October 2026: an exhausted cache (count 0) is legal; only a
|
|
// malformed or negative count is rejected.
|
|
for (const count of [undefined, -1, 0.5, Infinity, NaN])
|
|
assert.throws(
|
|
() => requireRicheseDeclarationCache(count),
|
|
RicheseSettlementError,
|
|
);
|
|
for (const count of [0, 1, 2, 10])
|
|
assert.doesNotThrow(() => requireRicheseDeclarationCache(count));
|
|
assert.deepEqual(value, before);
|
|
// A sold lot can first suspend for replacement/bonus; quote is not phase advancement.
|
|
assert.equal(quoteRicheseSettlement(input('blackMarket')).kind, 'sold');
|
|
});
|
|
|
|
void test('repeated pure preflight needs no RNG or UUID and returns only bounded settlement data', (t) => {
|
|
const value = input(),
|
|
before = structuredClone(value);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('No random draws');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('No new events');
|
|
});
|
|
const one = quoteRicheseSettlement(value);
|
|
assert.deepEqual(quoteRicheseSettlement(structuredClone(value)), one);
|
|
requireRicheseDeclarationCache(1);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
assert.deepEqual(value, before);
|
|
assert.deepEqual(Object.keys(one).sort(), [
|
|
'allyPayment',
|
|
'amount',
|
|
'card',
|
|
'kind',
|
|
'ownPayment',
|
|
'winner',
|
|
]);
|
|
});
|
|
|
|
/** Observe the existing production settlement for a behavioral comparison; no
|
|
* runtime export or gate is changed and no private helper is invoked as a quote. */
|
|
const observed: { settleRicheseLot?: (g: Game) => void } = {};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
|
'\nexport { settleRicheseLot };\n',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports: observed,
|
|
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
|
crypto: webcrypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
function engineFixture(source: RicheseAuctionSource) {
|
|
const g = createGame(
|
|
'SETTLEQUOTE',
|
|
newPlayer('r', 'Richese', 'richese'),
|
|
true,
|
|
);
|
|
g.players.push(
|
|
newPlayer('w', 'Winner', 'harkonnen'),
|
|
newPlayer('a', 'Ally', 'emperor'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 3,
|
|
turn: 2,
|
|
order: ['w', 'a', 'r'],
|
|
deck: baseDeck(),
|
|
richeseCache: richeseCards(),
|
|
});
|
|
for (const p of g.players) {
|
|
p.spice = 10;
|
|
p.hand = [];
|
|
p.traitorChoices = [];
|
|
p.traitors = [];
|
|
}
|
|
const value = input(source);
|
|
g.richeseAuction = structuredClone(value.lot);
|
|
if (source === 'blackMarket')
|
|
g.players[0].hand.push(g.richeseCache!.splice(0, 1)[0]);
|
|
g.players[1].ally = 'a';
|
|
g.players[2].ally = 'w';
|
|
g.aid.a = { recipient: 'w', amount: 2 };
|
|
g.richeseFunding = { w: { amount: 6, allyPayment: 2, donor: 'a' } };
|
|
g.richeseBidding = {
|
|
owner: 'r',
|
|
event: 'round2',
|
|
turn: 2,
|
|
stage: 'lot',
|
|
position: source === 'cache' ? 'first' : null,
|
|
normalCount: source === 'cache' ? 1 : null,
|
|
blackMarketSold: false,
|
|
cacheCanceled: false,
|
|
};
|
|
return g;
|
|
}
|
|
function engineInput(g: Game): RicheseSettlementInput {
|
|
const lot = g.richeseAuction!,
|
|
owner = g.players.find((p) => p.id === lot.owner)!,
|
|
winner =
|
|
lot.outcome?.kind === 'sold'
|
|
? g.players.find(
|
|
(p) => p.id === (lot.outcome as { winner: string }).winner,
|
|
)
|
|
: undefined;
|
|
return {
|
|
lot,
|
|
owner: {
|
|
id: owner.id,
|
|
handCount: owner.hand.length,
|
|
handLimit: handLimit(owner),
|
|
},
|
|
winner: winner
|
|
? {
|
|
id: winner.id,
|
|
handCount: winner.hand.length,
|
|
handLimit: handLimit(winner),
|
|
spice: winner.spice,
|
|
}
|
|
: null,
|
|
card: (lot.source === 'cache' ? g.richeseCache : owner.hand)?.find(
|
|
(c) => c.id === lot.cardId,
|
|
),
|
|
funding: winner ? g.richeseFunding?.[winner.id] : null,
|
|
allyCredit:
|
|
winner?.ally && g.aid[winner.ally]?.recipient === winner.id
|
|
? g.aid[winner.ally]
|
|
: null,
|
|
};
|
|
}
|
|
void test('quotes agree with real sold settlement debits, transfer and deferred Harkonnen bonus', () => {
|
|
for (const source of ['cache', 'blackMarket'] as const) {
|
|
const g = engineFixture(source),
|
|
before = structuredClone(g),
|
|
quote = quoteRicheseSettlement(engineInput(g));
|
|
assert.equal(quote.kind, 'sold');
|
|
if (quote.kind !== 'sold') throw Error('Missing sale quote');
|
|
assert.deepEqual(g, before);
|
|
observed.settleRicheseLot!(g);
|
|
assert.equal(
|
|
g.players[1].spice,
|
|
before.players[1].spice - quote.ownPayment,
|
|
);
|
|
assert.equal(g.aid.a.amount, before.aid.a.amount - quote.allyPayment);
|
|
assert.equal(
|
|
g.players[1].hand.filter((c) => c.id === quote.card.id).length,
|
|
1,
|
|
);
|
|
assert.equal(g.players[0].spice, before.players[0].spice + quote.amount);
|
|
assert.equal(g.response?.kind, 'harkonnenBonus');
|
|
assert.equal(g.currentAuctionSale?.free, false);
|
|
assert.equal(g.currentAuctionSale?.origin, source);
|
|
}
|
|
});
|
|
void test('an exhausted cache is legal and restores one ordinary lot in the Richese declaration', () => {
|
|
// User ruling 7 October 2026: when no cache lot can run (exhausted cache, or
|
|
// a canceled/prevented cache auction) the cache's one-lot reduction is not
|
|
// applied, so the ordinary pool runs one more lot.
|
|
const counts: number[] = [];
|
|
for (const exhausted of [false, true]) {
|
|
const g = engineFixture('blackMarket');
|
|
g.richeseAuction!.outcome = { kind: 'unbid' };
|
|
if (exhausted) g.richeseCache = [];
|
|
requireRicheseDeclarationCache(g.richeseCache?.length);
|
|
const before = structuredClone(g);
|
|
observed.settleRicheseLot!(g);
|
|
assert.equal(g.decision?.kind, 'richeseDeclaration');
|
|
assert.equal(g.richeseBidding!.blackMarketSold, false);
|
|
assert.deepEqual(g.players, before.players);
|
|
counts.push(g.richeseBidding!.normalCount!);
|
|
}
|
|
assert.equal(
|
|
counts[1],
|
|
counts[0] + 1,
|
|
'The exhausted cache restores exactly one ordinary lot',
|
|
);
|
|
});
|