import { BASE_TARGETS, HANDS, JOKERS, TAROTS, jokerDef } from '../content/catalog'; import { hash, random, shuffle } from './random'; import { scoreHand } from './scoring'; import type { Card, Command, Item, Run, Suit } from './types'; export function createRun(seed: string): Run { const r: Run = { version: 1, rules: 'velvet-v1', seed, phase: 'blind', ante: 1, blind: 0, round: 0, money: 4, score: 0, target: 300, hands: 4, discards: 3, handSize: 8, cards: [], hand: [], draw: [], spent: [], jokers: [], consumables: [], levels: HANDS.map(() => 1), counts: HANDS.map(() => 0), bosses: [], rng: { deck: hash(seed + 'deck'), shop: hash(seed + 'shop'), effect: hash(seed + 'effect') }, nextId: 100, shop: [], packs: [], packChoices: [], reroll: 5, freeReroll: false, investment: false, skips: 0, last: null, reward: null, best: 0, totalScore: 0, history: [], }; for (let suit = 0; suit < 4; suit++) for (let rank = 2; rank <= 14; rank++) r.cards.push({ id: r.cards.length + 1, rank, suit: suit as Suit }); r.bosses = [...shuffle(r, [0, 1, 2, 3, 4, 5], 'effect'), Math.floor(random(r, 'effect') * 6), 6]; return r; } export function target(r: Run, blind = r.blind) { return BASE_TARGETS[r.ante - 1] * (blind === 0 ? 1 : blind === 1 ? 1.5 : r.ante === 8 ? 4 : 2); } const fill = (r: Run) => { while (r.hand.length < r.handSize && r.draw.length) r.hand.push(r.draw.pop()!); }; function item(r: Run, kind: Item['kind'], ref: number, price: number): Item { return { uid: r.nextId++, kind, ref, price }; } function rollItem(r: Run, kind?: 'joker' | 'planet' | 'tarot', excluded: string[] = []): Item { const q = random(r, 'shop'); kind ??= q < 0.58 ? 'joker' : q < 0.81 ? 'planet' : 'tarot'; if (kind === 'joker') { const pool = JOKERS.filter( (j) => !r.jokers.some((x) => x.id === j.id) && !excluded.includes(j.id), ); if (pool.length) { const j = pool[Math.floor(random(r, 'shop') * pool.length)]; return item(r, 'joker', JOKERS.indexOf(j), j.price); } kind = 'planet'; } if (kind === 'planet') { const pool = HANDS.map((_, i) => i).filter((i) => i < 9 || r.counts[i] > 0); return item(r, 'planet', pool[Math.floor(random(r, 'shop') * pool.length)], 3); } return item(r, 'tarot', Math.floor(random(r, 'shop') * TAROTS.length), 3); } function stock(r: Run) { r.shop = []; for (let i = 0; i < 3; i++) r.shop.push( rollItem( r, i === 0 ? 'joker' : undefined, r.shop.filter((s) => s.kind === 'joker').map((s) => JOKERS[s.ref].id), ), ); } const requirePhase = (r: Run, ...phases: Run['phase'][]) => { if (!phases.includes(r.phase)) throw Error('当前阶段无法进行此操作'); }; function picked(r: Run, ids: number[]) { if ( !ids.length || ids.length > 5 || new Set(ids).size !== ids.length || ids.some((id) => !r.hand.includes(id)) ) throw Error('请选择 1–5 张手牌'); return ids.map((id) => r.cards.find((c) => c.id === id)!); } function advance(r: Run) { if (r.blind === 2) { r.blind = 0; r.ante++; } else r.blind++; r.phase = 'blind'; r.target = target(r); r.score = 0; r.hands = 4; r.discards = r.blind === 2 && r.bosses[r.ante - 1] === 3 ? 0 : 3; r.reward = null; r.last = null; r.hand = []; r.draw = []; r.spent = []; } function give(r: Run, i: Item) { if (i.kind === 'joker') { if (r.jokers.length >= 5) throw Error('小丑槽已满,请先出售一张'); r.jokers.push({ uid: i.uid, id: JOKERS[i.ref].id, value: 0 }); } else { if (r.consumables.length >= 2) throw Error('消耗品槽已满,请先使用或出售'); r.consumables.push(i); } } function apply(r: Run, c: Command) { switch (c.type) { case 'enter': { requirePhase(r, 'blind'); r.phase = 'play'; r.round++; r.score = 0; r.hands = 4; r.discards = r.blind === 2 && r.bosses[r.ante - 1] === 3 ? 0 : 3; r.handSize = r.blind === 2 && r.bosses[r.ante - 1] === 2 ? 7 : 8; r.target = target(r); r.hand = []; r.spent = []; r.draw = shuffle( r, r.cards.map((c) => c.id), ); r.last = null; r.reward = null; fill(r); if (!r.hand.length) r.phase = 'lost'; break; } case 'skip': requirePhase(r, 'blind'); if (r.blind === 2) throw Error('Boss 盲注不可跳过'); r.skips++; if (r.blind === 0) r.freeReroll = true; else r.investment = true; advance(r); break; case 'play': { requirePhase(r, 'play'); const cards = picked(r, c.ids); if (r.hands <= 0) throw Error('没有出牌次数'); if (r.blind === 2 && r.bosses[r.ante - 1] === 4 && cards.length !== 5) throw Error('这个 Boss 要求每次打出 5 张牌'); const result = scoreHand(r, cards); r.last = result; r.hands--; r.score += result.total; r.totalScore += result.total; r.best = Math.max(r.best, result.total); r.hand = r.hand.filter((id) => !c.ids.includes(id)); r.spent.push(...c.ids.filter((id) => r.cards.some((x) => x.id === id))); r.history.unshift(`${result.name} · ${result.total.toLocaleString()} 分`); r.history = r.history.slice(0, 20); if (r.score >= r.target) { const base = 3 + r.blind, interest = Math.min(5, Math.floor(r.money / 5)), bonus = r.jokers.filter((j) => j.id === 'egg').length * 4 + (r.blind === 2 && r.investment ? 15 : 0); r.reward = { base, hands: r.hands, interest, bonus, total: base + r.hands + interest + bonus, }; r.money += r.reward.total; if (r.blind === 2) r.investment = false; r.phase = r.ante === 8 && r.blind === 2 ? 'won' : 'reward'; } else if (r.hands === 0 || r.hand.length + r.draw.length === 0) r.phase = 'lost'; else fill(r); break; } case 'discard': { requirePhase(r, 'play'); picked(r, c.ids); if (r.discards <= 0) throw Error('没有弃牌次数'); r.discards--; r.hand = r.hand.filter((id) => !c.ids.includes(id)); r.spent.push(...c.ids); for (const j of r.jokers) if (j.id === 'growth') j.value = Math.max(0, j.value - 1); fill(r); if (!r.hand.length) r.phase = 'lost'; break; } case 'cashout': requirePhase(r, 'reward'); r.phase = 'shop'; r.reroll = 5; stock(r); r.packs = [item(r, 'pack', 0, 4), item(r, 'pack', 1, 4)]; break; case 'next': requirePhase(r, 'shop'); advance(r); break; case 'reroll': requirePhase(r, 'shop'); if (!r.freeReroll) { if (r.money < r.reroll) throw Error('余额不足'); r.money -= r.reroll; r.reroll++; } else r.freeReroll = false; stock(r); break; case 'buy': { requirePhase(r, 'shop'); const i = [...r.shop, ...r.packs].find((i) => i.uid === c.uid); if (!i) throw Error('商品已售出'); if (r.money < i.price) throw Error('余额不足'); if (i.kind === 'pack') { r.packChoices = []; for (let n = 0; n < 3; n++) r.packChoices.push( rollItem( r, i.ref === 0 ? 'joker' : random(r, 'shop') < 0.5 ? 'planet' : 'tarot', r.packChoices.filter((x) => x.kind === 'joker').map((x) => JOKERS[x.ref].id), ), ); r.phase = 'pack'; r.packs = r.packs.filter((x) => x.uid !== i.uid); } else { give(r, i); r.shop = r.shop.filter((x) => x.uid !== i.uid); } r.money -= i.price; break; } case 'take': { requirePhase(r, 'pack'); const i = r.packChoices.find((i) => i.uid === c.uid); if (!i) throw Error('无效的卡包选项'); give(r, i); r.packChoices = []; r.phase = 'shop'; break; } case 'closePack': requirePhase(r, 'pack'); r.packChoices = []; r.phase = 'shop'; break; case 'sell': { requirePhase(r, 'shop', 'play', 'blind'); const j = r.jokers.find((j) => j.uid === c.uid), i = r.consumables.find((i) => i.uid === c.uid); if (!j && !i) throw Error('卡牌不存在'); r.money += Math.max(1, Math.floor((j ? jokerDef(j.id).price : i!.price) / 2)); r.jokers = r.jokers.filter((j) => j.uid !== c.uid); r.consumables = r.consumables.filter((i) => i.uid !== c.uid); break; } case 'use': { requirePhase(r, 'play'); const i = r.consumables.find((i) => i.uid === c.uid); if (!i) throw Error('消耗品不存在'); if (i.kind === 'planet') r.levels[i.ref]++; else { const cs = picked(r, c.ids); if (cs.length > TAROTS[i.ref].max) throw Error(`最多选择 ${TAROTS[i.ref].max} 张牌`); if (i.ref === 4 && r.cards.length <= cs.length) throw Error('牌组至少保留 1 张牌'); for (const card of cs) { if (i.ref === 0) card.suit = 0; if (i.ref === 1) card.suit = 1; if (i.ref === 2) card.rank = card.rank === 14 ? 2 : card.rank + 1; if (i.ref === 3) { const copy = { ...card, id: r.nextId++ }; r.cards.push(copy); r.hand.push(copy.id); } if (i.ref === 4) { r.cards = r.cards.filter((x) => x.id !== card.id); r.hand = r.hand.filter((id) => id !== card.id); } if (i.ref === 5) card.enh = 'glass'; if (i.ref === 6) card.enh = 'steel'; if (i.ref === 7) { card.enh = 'bonus'; card.seal = true; } if (i.ref === 8) card.enh = 'mult'; } } r.consumables = r.consumables.filter((x) => x.uid !== c.uid); break; } case 'order': { requirePhase(r, 'play', 'shop', 'blind'); const old = c.zone === 'hand' ? r.hand : r.jokers.map((j) => j.uid); if ( old.length !== c.ids.length || new Set(c.ids).size !== old.length || c.ids.some((id) => !old.includes(id)) ) throw Error('无效的排序'); if (c.zone === 'hand') r.hand = c.ids; else r.jokers = c.ids.map((id) => r.jokers.find((j) => j.uid === id)!); break; } } } export function dispatch(state: Run, command: Command): { state: Run; error?: string } { const next = structuredClone(state); try { apply(next, command); // A depleted or heavily trimmed deck must never leave an unwinnable input screen. if (next.phase === 'play') { const required = next.blind === 2 && next.bosses[next.ante - 1] === 4 ? 5 : 1; const canDraw = next.discards > 0 && next.hand.length > 0 && next.draw.length > 0 && Math.min(next.handSize, next.hand.length - 1 + next.draw.length) >= required; const copies = next.consumables.filter((i) => i.kind === 'tarot' && i.ref === 3).length; const canCopy = next.hand.length > 0 && next.hand.length + copies >= required; if (next.hand.length < required && !canDraw && !canCopy) next.phase = 'lost'; } return { state: next }; } catch (e) { return { state, error: e instanceof Error ? e.message : '操作失败' }; } } export function cardName(c: Card) { return ( ['红桃', '黑桃', '方块', '梅花'][c.suit] + ({ 11: 'J', 12: 'Q', 13: 'K', 14: 'A' }[c.rank] || c.rank) ); }