Files
xiaochoupai/src/core/run.ts
richarjiang 12380c715c first commit
2026-09-21 14:45:24 +08:00

350 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
);
}