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 = { 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);