147 lines
4.4 KiB
TypeScript
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);
|