Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,178 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user