Files
xiaochoupai/tests/balance.test.ts
richarjiang 12380c715c first commit
2026-09-21 14:45:24 +08:00

147 lines
4.4 KiB
TypeScript

import { expect, it } from 'vitest';
import { createRun, dispatch } from '../src/core/run';
import { scoreHand } from '../src/core/scoring';
import { evaluate } from '../src/core/poker';
import { JOKERS, TAROTS } from '../src/content/catalog';
import { validateRun } from '../src/platform/storage';
import type { Run, Command } from '../src/core/types';
// A bounded, simple greedy player; this checks reachability, not human difficulty.
const weights: Record<string, number> = {
growth: 130,
scholar: 120,
minimal: 110,
double: 100,
chips: 85,
bank: 80,
echo: 65,
pair: 45,
smile: 30,
egg: 25,
last: 50,
hearts: 40,
spades: 35,
clubs: 35,
aces: 30,
even: 35,
odd: 35,
faces: 35,
diamonds: 20,
moon: 15,
triple: 40,
flush: 40,
straight: 30,
wanderer: 5,
};
function best(r: Run) {
let score = -1,
ids: number[] = [];
const visit = (at: number, chosen: number[]) => {
if (chosen.length) {
if (!(r.blind === 2 && r.bosses[r.ante - 1] === 4 && chosen.length !== 5)) {
const s = scoreHand(
structuredClone(r),
chosen.map((id) => r.cards.find((c) => c.id === id)!),
).total;
if (s > score) {
score = s;
ids = [...chosen];
}
}
}
if (chosen.length === 5) return;
for (let i = at; i < r.hand.length; i++) visit(i + 1, [...chosen, r.hand[i]]);
};
visit(0, []);
return { score, ids };
}
function simulate(seed: string) {
let r = createRun(seed),
steps = 0;
const act = (c: Command) => {
const res = dispatch(r, c);
if (res.error) throw Error(res.error);
r = res.state;
expect(validateRun(r)).toBe(true);
};
while (!['won', 'lost'].includes(r.phase) && steps++ < 450) {
if (r.phase === 'blind') {
act({ type: 'enter' });
continue;
}
if (r.phase === 'reward') {
act({ type: 'cashout' });
continue;
}
if (r.phase === 'shop') {
const score = (id: string) => weights[id] || 10;
for (const it of [...r.shop].sort(
(a, b) =>
(b.kind === 'joker' ? score(JOKERS[b.ref].id) : 0) -
(a.kind === 'joker' ? score(JOKERS[a.ref].id) : 0),
)) {
if (it.price > r.money) continue;
if (it.kind === 'joker') {
if (r.jokers.length === 5) {
const low = [...r.jokers].sort((a, b) => score(a.id) - score(b.id))[0];
if (score(JOKERS[it.ref].id) <= score(low.id)) continue;
act({ type: 'sell', uid: low.uid });
}
act({ type: 'buy', uid: it.uid });
} else if (
r.consumables.length < 2 &&
it.kind === 'planet' &&
(it.ref < 3 || r.counts[it.ref] > 2)
)
act({ type: 'buy', uid: it.uid });
}
if (r.money > 12 && r.reroll <= 7) {
act({ type: 'reroll' });
continue;
}
const ids = [...r.jokers]
.sort(
(a, b) =>
(['double', 'minimal', 'last', 'wanderer'].includes(a.id) ? 1 : 0) -
(['double', 'minimal', 'last', 'wanderer'].includes(b.id) ? 1 : 0),
)
.map((j) => j.uid);
act({ type: 'order', zone: 'jokers', ids });
act({ type: 'next' });
continue;
}
if (r.phase === 'play') {
for (const item of [...r.consumables]) {
const ids = item.kind === 'planet' ? [] : r.hand.slice(0, TAROTS[item.ref].max);
act({ type: 'use', uid: item.uid, ids });
}
const b = best(r);
if (r.discards > 0 && b.score < ((r.target - r.score) / r.hands) * 1.3) {
const keep = evaluate(b.ids.map((id) => r.cards.find((c) => c.id === id)!)).scoring.map(
(c) => c.id,
);
const drop = r.hand.filter((id) => !keep.includes(id)).slice(0, 5);
if (drop.length && r.draw.length) {
act({ type: 'discard', ids: drop });
continue;
}
}
act({ type: 'play', ids: b.ids });
}
}
return {
seed,
phase: r.phase,
ante: r.ante,
round: r.round,
best: r.best,
jokers: r.jokers.map((j) => j.id),
};
}
it('固定种子的自然规则对局不会陷入非法状态,并至少可达成一次通关', () => {
const results = Array.from({ length: 16 }, (_, i) => simulate(`VELVET-${i + 1}`));
console.log('Greedy baseline (no altered cards / money / levels):', JSON.stringify(results));
expect(results.every((r) => r.phase === 'won' || r.phase === 'lost')).toBe(true);
expect(results.some((r) => r.phase === 'won')).toBe(true);
}, 60000);