first commit
This commit is contained in:
146
tests/balance.test.ts
Normal file
146
tests/balance.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user