274 lines
8.4 KiB
TypeScript
274 lines
8.4 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 ts from 'typescript';
|
|
import {
|
|
createGame,
|
|
newPlayer,
|
|
applyAction,
|
|
viewGame,
|
|
type Game,
|
|
type ResponseWindow,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import {
|
|
quoteChoamSaleCancellation,
|
|
ChoamSaleCancellationError,
|
|
} from '../game/choam-sale-cancellation';
|
|
function fixture(duplicate = false) {
|
|
let g = createGame('SALEQUOTE', newPlayer('c', 'CHOAM', 'choam'), true, [
|
|
'choam',
|
|
]);
|
|
g.players.push(
|
|
newPlayer('b', 'BG', 'beneGesserit'),
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 2,
|
|
turn: 2,
|
|
storm: 18,
|
|
order: ['c', 'b', 'e'],
|
|
deck: baseDeck(),
|
|
phaseOpening: null,
|
|
choamCharity: { turn: 2, canceled: false },
|
|
});
|
|
for (const p of g.players)
|
|
Object.assign(p, {
|
|
hand: [],
|
|
spice: 20,
|
|
forces: {},
|
|
reserves: 20,
|
|
traitors: [],
|
|
traitorChoices: [],
|
|
});
|
|
const hold = (who: number, kind: string) => {
|
|
const at = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
|
|
assert.ok(at >= 0);
|
|
const card = g.deck.splice(at, 1)[0];
|
|
g.players[who].hand.push(card);
|
|
return card;
|
|
};
|
|
const card = hold(0, duplicate ? 'snooper' : 'worthless'),
|
|
witness = duplicate ? hold(0, 'snooper') : null,
|
|
cost = hold(1, 'worthless'),
|
|
printed = hold(2, 'karama');
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
g = applyAction(g, 'c', {
|
|
type: 'decision',
|
|
mode: 'sell',
|
|
card: card.id,
|
|
...(witness ? { witness: witness.id } : {}),
|
|
});
|
|
assert.equal(g.response?.kind, 'choamSale');
|
|
return { g, card, witness, cost, printed };
|
|
}
|
|
function cancel(g: Game, bg: boolean, card: string) {
|
|
let done = applyAction(g, bg ? 'b' : 'e', {
|
|
type: 'card',
|
|
card,
|
|
mode: 'cancel',
|
|
});
|
|
for (let tries = 0; done.response?.kind === 'worthlessKarama'; tries++) {
|
|
assert.ok(tries < 10);
|
|
done = applyAction(
|
|
done,
|
|
done.players.find((p) => !done.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
}
|
|
return done;
|
|
}
|
|
for (const duplicate of [false, true])
|
|
for (const bg of [false, true])
|
|
void test(`${bg ? 'BG' : 'printed'} cancellation preserves ${duplicate ? 'duplicate' : 'Worthless'} sale, price and exact next market`, () => {
|
|
const f = fixture(duplicate),
|
|
before = structuredClone(f.g),
|
|
q = quoteChoamSaleCancellation(f.g, f.g.response!)!;
|
|
assert.deepEqual(f.g, before);
|
|
const spent = bg ? f.cost : f.printed,
|
|
done = cancel(JSON.parse(JSON.stringify(f.g)), bg, spent.id);
|
|
assert.deepEqual(done.choamMarket, q.market);
|
|
assert.deepEqual(done.decision, q.decision);
|
|
assert.equal(done.players[0].spice, 20);
|
|
assert.ok(done.players[0].hand.some((c) => c.id === f.card.id));
|
|
assert.equal(done.discard.filter((c) => c.id === spent.id).length, 1);
|
|
assert.equal(
|
|
done.discard.some((c) => c.id === f.card.id),
|
|
false,
|
|
);
|
|
assert.deepEqual(viewGame(done, 'e').choamMarket, { owner: 'c' });
|
|
assert.throws(() =>
|
|
applyAction(done, 'c', {
|
|
type: 'decision',
|
|
mode: 'sell',
|
|
card: f.card.id,
|
|
witness: f.witness?.id,
|
|
}),
|
|
);
|
|
});
|
|
void test('denied sale does not require its selected card, duplicate witness or current funds to remain available', () => {
|
|
const f = fixture(true);
|
|
f.g.discard.push(...f.g.players[0].hand);
|
|
f.g.players[0].hand = [];
|
|
f.g.players[0].spice = 0;
|
|
const q = quoteChoamSaleCancellation(f.g, f.g.response!)!;
|
|
const done = cancel(f.g, true, f.cost.id);
|
|
assert.deepEqual(done.choamMarket, q.market);
|
|
assert.equal(done.decision?.kind, 'choamMarket');
|
|
assert.equal(done.players[0].spice, 0);
|
|
});
|
|
void test('a canceled BG conversion restores the exact sale instead of denying it', () => {
|
|
const f = fixture();
|
|
let g = applyAction(f.g, 'b', {
|
|
type: 'card',
|
|
card: f.cost.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
g = applyAction(g, 'e', { type: 'card', card: f.printed.id, mode: 'cancel' });
|
|
assert.equal(g.choamMarket?.blocked.length, 0);
|
|
// No blocking cards remain, so the original sale is automatically allowed.
|
|
assert.equal(g.players[0].spice, 22);
|
|
assert.equal(g.discard.filter((c) => c.id === f.card.id).length, 1);
|
|
assert.equal(g.discard.filter((c) => c.id === f.cost.id).length, 1);
|
|
assert.equal(g.discard.filter((c) => c.id === f.printed.id).length, 1);
|
|
});
|
|
void test('malformed market owner, resume, blocked list and sale shape reject before both costs without mutation', () => {
|
|
const f = fixture();
|
|
const corrupt: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.choamMarket = null;
|
|
},
|
|
(g) => {
|
|
g.choamMarket!.owner = 'e';
|
|
},
|
|
(g) => {
|
|
g.phase = 1;
|
|
g.choamMarket!.resume = 'storm';
|
|
},
|
|
(g) => {
|
|
g.choamMarket!.blocked = ['same', 'same'];
|
|
},
|
|
(g) => {
|
|
g.choamMarket!.blocked = [f.card.id];
|
|
},
|
|
(g) => {
|
|
g.choamMarket!.sale!.price = 99;
|
|
},
|
|
(g) => {
|
|
g.choamMarket!.sale!.witness = f.card.id;
|
|
},
|
|
(g) => {
|
|
delete g.choamMarket!.sale;
|
|
},
|
|
(g) => {
|
|
g.choamMarket!.trade = { ally: 'e', offered: f.card.id };
|
|
},
|
|
];
|
|
for (const change of corrupt)
|
|
for (const bg of [false, true]) {
|
|
const g = structuredClone(f.g);
|
|
change(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() => quoteChoamSaleCancellation(g, g.response!),
|
|
ChoamSaleCancellationError,
|
|
);
|
|
assert.throws(() =>
|
|
applyAction(g, bg ? 'b' : 'e', {
|
|
type: 'card',
|
|
card: (bg ? f.cost : f.printed).id,
|
|
mode: 'cancel',
|
|
}),
|
|
);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|
|
void test('pure sale cancellation reads no private hand and neither samples nor allocates events', (t) => {
|
|
const f = fixture(true),
|
|
q = quoteChoamSaleCancellation(f.g, f.g.response!),
|
|
before = structuredClone(f.g);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('RNG');
|
|
}),
|
|
uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('UUID');
|
|
});
|
|
for (const p of f.g.players)
|
|
for (const field of ['hand', 'spice', 'leaders', 'traitors'])
|
|
Object.defineProperty(p, field, {
|
|
get() {
|
|
throw Error('Private ' + field);
|
|
},
|
|
});
|
|
for (let i = 0; i < 10; i++)
|
|
assert.deepEqual(quoteChoamSaleCancellation(f.g, f.g.response!), q);
|
|
assert.deepEqual(q?.market.blocked, [f.card.id]);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
assert.deepEqual(before.choamMarket?.blocked, []);
|
|
});
|
|
void test('storm market resume remains a decision and unrelated response families remain outside this quote', () => {
|
|
const f = fixture();
|
|
f.g.phase = 0;
|
|
f.g.choamMarket!.resume = 'storm';
|
|
assert.equal(
|
|
quoteChoamSaleCancellation(f.g, f.g.response!)?.market.resume,
|
|
'storm',
|
|
);
|
|
assert.equal(
|
|
quoteChoamSaleCancellation(f.g, {
|
|
kind: 'guildIncome',
|
|
owner: 'e',
|
|
passed: [],
|
|
}),
|
|
null,
|
|
);
|
|
});
|
|
const observed: {} & { finishResponse?: (g: Game, canceled: boolean) => void } =
|
|
{};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
|
'\nexport {finishResponse};',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports: observed,
|
|
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
|
crypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
void test('an unknown saved response cannot fall through to free advisor shipment in either direction', () => {
|
|
for (const canceled of [false, true]) {
|
|
const g = createGame('UNKNOWN', newPlayer('b', 'BG', 'beneGesserit'));
|
|
g.status = 'playing';
|
|
g.phase = 5;
|
|
g.response = {
|
|
kind: 'unknownPower',
|
|
owner: 'b',
|
|
passed: [],
|
|
} as unknown as ResponseWindow;
|
|
const p = structuredClone(g.players[0]),
|
|
logs = structuredClone(g.log);
|
|
assert.throws(
|
|
() => observed.finishResponse!(g, canceled),
|
|
/Unknown faction response/,
|
|
);
|
|
assert.deepEqual(g.players[0], p);
|
|
assert.deepEqual(g.log, logs);
|
|
}
|
|
});
|