first commit

This commit is contained in:
richarjiang
2026-09-21 14:45:24 +08:00
commit 12380c715c
34 changed files with 6609 additions and 0 deletions

349
src/core/run.ts Normal file
View File

@@ -0,0 +1,349 @@
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)
);
}