350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
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)
|
||
);
|
||
}
|