179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type GameView,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
|
|
function fixture() {
|
|
const g = createGame('BOXBOT', newPlayer('d', 'Donor', 'atreides'));
|
|
g.players.push(newPlayer('a', 'Other', 'emperor'));
|
|
g.status = 'playing';
|
|
g.advanced = true;
|
|
g.phase = 2;
|
|
g.turn = 2;
|
|
g.order = ['d', 'a'];
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 10;
|
|
}
|
|
g.players[0].hand = [
|
|
richeseCards().find((c) => c.effect === 'nullentropyBox')!,
|
|
];
|
|
const deck = baseDeck();
|
|
const weapon = deck.find((c) => c.kind === 'projectile')!;
|
|
const karama = deck.find((c) => c.effect === 'karama')!;
|
|
const unsupported = richeseCards().find((c) => c.effect === 'stoneBurner')!;
|
|
g.discard = [unsupported, weapon, karama];
|
|
g.deck = deck.filter(
|
|
(c) => !g.discard.some((discarded) => discarded.id === c.id),
|
|
);
|
|
return g;
|
|
}
|
|
function projected(g: Game, difficulty: Difficulty) {
|
|
const v = viewGame(g, 'd');
|
|
v.players[0].bot = difficulty;
|
|
return v;
|
|
}
|
|
const beginAction = (v: GameView) =>
|
|
botActions(v).find(
|
|
(a) => a.type === 'card' && a.card === 'richese-nullentropy-box',
|
|
);
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
void test('all profiles pay once, resolve their private search using functional cards, and cannot replay after consumption', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture(),
|
|
before = projected(g, difficulty);
|
|
assert.equal(before.nullentropy!.search, null);
|
|
const start = beginAction(before);
|
|
assert.ok(start, difficulty);
|
|
assert.deepEqual(start, { type: 'card', card: 'richese-nullentropy-box' });
|
|
const paid = applyAction(g, 'd', start);
|
|
assert.equal(paid.players[0].spice, 8);
|
|
assert.equal(paid.decision?.kind, 'nullentropy');
|
|
assert.equal(viewGame(paid, 'a').nullentropy, null);
|
|
const v = projected(reload(paid), difficulty),
|
|
snapshot = structuredClone(v);
|
|
const choice = botActions(v)[0];
|
|
assert.deepEqual(v, snapshot);
|
|
assert.equal(choice.type, 'decision');
|
|
assert.equal(choice.event, v.nullentropy!.search!.event);
|
|
assert.ok(v.nullentropy!.search!.cards.some((c) => c.id === choice.card));
|
|
assert.notEqual(choice.card, 'richese-stone-burner');
|
|
const finished = applyAction(paid, 'd', choice);
|
|
assert.equal(finished.players[0].spice, 8);
|
|
assert.deepEqual(
|
|
finished.players[0].hand.map((c) => c.id),
|
|
[choice.card],
|
|
);
|
|
assert.equal(finished.discard.at(-1)!.id, start.card);
|
|
assert.equal(finished.discard.filter((c) => c.id === start.card).length, 1);
|
|
assert.equal(finished.decision, null);
|
|
assert.equal(
|
|
beginAction(projected(reload(finished), difficulty)),
|
|
undefined,
|
|
);
|
|
assert.throws(() => applyAction(finished, 'd', choice));
|
|
assert.throws(() => applyAction(finished, 'd', start));
|
|
}
|
|
});
|
|
|
|
void test('optional beginning uses only owner budget and availability, with no prepayment discard lookahead', () => {
|
|
for (const [level, difficulty] of DIFFICULTIES.entries()) {
|
|
const g = fixture(),
|
|
v = projected(g, difficulty);
|
|
const ordinary = beginAction(v);
|
|
const changed = fixture();
|
|
changed.discard = baseDeck()
|
|
.filter((c) => c.kind === 'worthless')
|
|
.slice(0, 2);
|
|
assert.deepEqual(beginAction(projected(changed, difficulty)), ordinary);
|
|
assert.equal(v.nullentropy!.search, null);
|
|
assert.ok(!JSON.stringify(v.nullentropy).includes('Stone Burner'));
|
|
v.players[0].spice = [5, 6, 7, 8][level] - 1;
|
|
assert.equal(beginAction(v), undefined);
|
|
v.players[0].spice++;
|
|
assert.deepEqual(beginAction(v), ordinary);
|
|
v.nullentropy!.blocked = 'Keep a pre-existing free hand slot.';
|
|
assert.equal(beginAction(v), undefined);
|
|
v.nullentropy = null;
|
|
assert.equal(beginAction(v), undefined);
|
|
}
|
|
});
|
|
|
|
void test('paid searches finish before unrelated optional actions and only their owner can choose', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
const paid = applyAction(g, 'd', beginAction(projected(g, difficulty))!);
|
|
const v = projected(paid, difficulty);
|
|
v.players[0].spice = 0;
|
|
const choice = botActions(v)[0];
|
|
assert.equal(choice.type, 'decision');
|
|
assert.equal(choice.event, v.nullentropy!.search!.event);
|
|
const other = viewGame(paid, 'a');
|
|
other.players[1].bot = difficulty;
|
|
assert.deepEqual(botActions(other), []);
|
|
const missing = projected(paid, difficulty);
|
|
missing.nullentropy!.search = null;
|
|
assert.deepEqual(botActions(missing), []);
|
|
const truth = projected(g, difficulty);
|
|
truth.truthtrance = {
|
|
stage: 'priority',
|
|
queue: [],
|
|
passed: [],
|
|
} as unknown as NonNullable<GameView['truthtrance']>;
|
|
assert.equal(botActions(truth)[0].type, 'truthPass');
|
|
const decision = projected(g, difficulty);
|
|
decision.decision = { kind: 'richeseDeclaration', player: 'a' };
|
|
assert.deepEqual(botActions(decision), []);
|
|
const response = projected(g, difficulty);
|
|
response.response = { kind: 'emperorIncome', owner: 'a', passed: [] };
|
|
assert.equal(beginAction(response), undefined);
|
|
}
|
|
});
|
|
|
|
void test('a sole eligible card settles automatically, with no inspection acknowledgement for the AI', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
g.discard = [g.discard[1]];
|
|
const selected = g.discard[0].id;
|
|
const finished = applyAction(
|
|
g,
|
|
'd',
|
|
beginAction(projected(g, difficulty))!,
|
|
);
|
|
assert.equal(finished.decision, null);
|
|
assert.equal(finished.players[0].spice, 8);
|
|
assert.deepEqual(
|
|
finished.players[0].hand.map((c) => c.id),
|
|
[selected],
|
|
);
|
|
assert.equal(finished.discard.at(-1)!.id, 'richese-nullentropy-box');
|
|
assert.equal(projected(finished, difficulty).nullentropy, null);
|
|
}
|
|
});
|
|
|
|
void test('even a paid pile containing only unfinished non-Box faces has a finite legal selection', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
g.discard = richeseCards().filter((c) =>
|
|
['stoneBurner', 'semutaDrug'].includes(c.effect),
|
|
);
|
|
const paid = applyAction(g, 'd', beginAction(projected(g, difficulty))!);
|
|
const choice = botActions(projected(paid, difficulty))[0];
|
|
assert.ok(g.discard.some((c) => c.id === choice.card));
|
|
const finished = applyAction(paid, 'd', choice);
|
|
assert.equal(finished.decision, null);
|
|
assert.equal(finished.players[0].hand[0].id, choice.card);
|
|
}
|
|
});
|