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

355
tests/core.test.ts Normal file
View File

@@ -0,0 +1,355 @@
import { describe, it, expect } from 'vitest';
import { createRun, dispatch, target } from '../src/core/run';
import { evaluate } from '../src/core/poker';
import { scoreHand } from '../src/core/scoring';
import { validateRun } from '../src/platform/storage';
import { JOKERS } from '../src/content/catalog';
import type { Card, Run, Command } from '../src/core/types';
const cards = (ranks: number[], suits: number[] = []): Card[] =>
ranks.map((rank, i) => ({ id: i + 1, rank, suit: (suits[i] ?? i % 4) as Card['suit'] }));
function fixture(cs: Card[], jokers: string[] = []) {
const r = createRun('TEST');
r.phase = 'play';
r.cards = cs;
r.hand = cs.map((c) => c.id);
r.draw = [];
r.jokers = jokers.map((id, i) => ({ id, uid: 100 + i, value: 0 }));
return r;
}
function act(r: Run, c: Command) {
const x = dispatch(r, c);
expect(x.error).toBeUndefined();
return x.state;
}
describe('牌型分类与计分牌', () => {
const cases: [number[], number[], number, number][] = [
[[14, 9, 7, 4, 2], [], 0, 1],
[[13, 13, 7], [], 1, 2],
[[13, 13, 7, 7, 2], [], 2, 4],
[[9, 9, 9, 2, 3], [], 3, 3],
[[2, 3, 4, 5, 6], [], 4, 5],
[[2, 5, 7, 9, 13], [0, 0, 0, 0, 0], 5, 5],
[[8, 8, 8, 5, 5], [], 6, 5],
[[8, 8, 8, 8, 2], [], 7, 4],
[[2, 3, 4, 5, 6], [0, 0, 0, 0, 0], 8, 5],
[[8, 8, 8, 8, 8], [], 9, 5],
[[8, 8, 8, 5, 5], [1, 1, 1, 1, 1], 10, 5],
[[8, 8, 8, 8, 8], [1, 1, 1, 1, 1], 11, 5],
];
it.each(cases)('%j → 牌型 %s', (rs, ss, hand, count) => {
const e = evaluate(cards(rs, ss));
expect(e.hand).toBe(hand);
expect(e.scoring).toHaveLength(count);
});
it('A 可高可低,但不能绕接', () => {
expect(evaluate(cards([14, 2, 3, 4, 5])).hand).toBe(4);
expect(evaluate(cards([10, 11, 12, 13, 14])).hand).toBe(4);
expect(evaluate(cards([12, 13, 14, 2, 3])).hand).toBe(0);
});
it('牌型包含关系不依赖最终牌型名称', () => {
expect(evaluate(cards([8, 8, 8, 5, 5]))).toMatchObject({ hand: 6, pair: true, triple: true });
});
it('计分牌保留用户顺序', () => {
const cs = cards([3, 13, 3, 13, 5]);
expect(evaluate(cs).scoring.map((c) => c.id)).toEqual([1, 2, 3, 4]);
});
});
describe('分阶段计分金样', () => {
it('K K 7:只有对子计分,30×2=60', () => {
const cs = cards([13, 13, 7]);
expect(scoreHand(fixture(cs), cs).total).toBe(60);
});
it('加倍率 / 乘倍率顺序分别为 360 / 240', () => {
const cs = cards([13, 13, 7]);
expect(scoreHand(fixture(cs, ['smile', 'double']), cs).total).toBe(360);
expect(scoreHand(fixture(cs, ['double', 'smile']), cs).total).toBe(240);
});
it('红封和回声相加,不递归:高牌 A 触发三次,38 分', () => {
const cs = cards([14]);
cs[0].seal = true;
expect(scoreHand(fixture(cs, ['echo']), cs).total).toBe(38);
});
it('红封完整重触发逐张小丑', () => {
const cs = cards([14], [0]);
cs[0].seal = true;
expect(scoreHand(fixture(cs, ['hearts']), cs).total).toBe(27 * 9);
});
it('留手钢铁先于月后:16 × (1×1.5+13)', () => {
const cs = cards([14, 12]);
cs[1].enh = 'steel';
expect(scoreHand(fixture(cs, ['moon']), [cs[0]]).total).toBe(232);
});
it('失效牌能成对子但不给点数与逐张加成', () => {
const cs = cards([13, 13], [0, 0]),
r = fixture(cs, ['hearts']);
r.blind = 2;
r.bosses[0] = 0;
expect(scoreHand(r, cs).total).toBe(20);
});
it('成长在当前出牌即生效', () => {
const cs = cards([14]),
r = fixture(cs, ['growth']);
expect(scoreHand(r, cs).total).toBe(48);
expect(r.jokers[0].value).toBe(2);
});
it('牌型等级修改基础值', () => {
const cs = cards([13, 13]),
r = fixture(cs);
r.levels[1] = 2;
expect(scoreHand(r, cs).total).toBe(135);
});
it('钝刃基础值向上取整', () => {
const cs = cards([14]),
r = fixture(cs);
r.blind = 2;
r.bosses[0] = 5;
expect(scoreHand(r, cs).total).toBe(14);
});
it('非计分玻璃牌不会触发或破碎', () => {
const cs = cards([13, 13, 7]),
r = fixture(cs);
cs[2].enh = 'glass';
const rng = r.rng.effect;
expect(scoreHand(r, cs).total).toBe(60);
expect(r.cards).toHaveLength(3);
expect(r.rng.effect).toBe(rng);
});
it('最后一次出牌 ×3 以扣除次数前判断', () => {
const cs = cards([14]),
r = fixture(cs, ['last']);
r.hands = 1;
expect(scoreHand(r, cs).total).toBe(48);
});
it('逐张经济先于银行家独立触发', () => {
const cs = cards([14], [2]),
r = fixture(cs, ['diamonds', 'bank']);
r.money = 4;
expect(scoreHand(r, cs).total).toBe(80);
expect(r.money).toBe(5);
});
it.each(JOKERS.map((j) => [j.id]))('所有小丑 %s 的结果与事件有限', (id) => {
const cs = cards([13, 13, 13, 7, 7]),
r = fixture(cs, [id]);
const s = scoreHand(r, cs);
expect(Number.isFinite(s.total)).toBe(true);
expect(s.trace.at(-1)).toMatchObject({ chips: s.chips, mult: s.mult });
});
});
describe('对局、经济与事务', () => {
it('相同种子产生相同状态与抽牌', () => {
expect(act(createRun('SEED'), { type: 'enter' })).toEqual(
act(createRun('SEED'), { type: 'enter' }),
);
});
it('弃牌不回牌库,卡牌守恒', () => {
let r = act(createRun('S'), { type: 'enter' });
for (let i = 0; i < 3; i++) {
r = act(r, { type: 'discard', ids: r.hand.slice(0, 5) });
expect(new Set([...r.hand, ...r.draw, ...r.spent]).size).toBe(52);
expect(r.hand).toHaveLength(8);
}
expect(r.draw).toHaveLength(29);
});
it('非法操作原子拒绝', () => {
const r = createRun('S');
const before = structuredClone(r);
expect(dispatch(r, { type: 'play', ids: [1] }).error).toBeTruthy();
expect(r).toEqual(before);
expect(dispatch(r, { type: 'play', ids: [1] }).state).toBe(r);
});
it('重复选择同一实例不能计分两次', () => {
const r = act(createRun('S'), { type: 'enter' });
expect(dispatch(r, { type: 'play', ids: [r.hand[0], r.hand[0]] }).error).toBeTruthy();
});
it('胜利即时结算奖励,cashout 不再加钱', () => {
const cs = cards([14]),
r = fixture(cs);
r.target = 1;
r.money = 25;
const next = act(r, { type: 'play', ids: [1] });
expect(next.phase).toBe('reward');
expect(next.money).toBe(36);
const shop = act(next, { type: 'cashout' });
expect(shop.money).toBe(36);
expect(dispatch(shop, { type: 'cashout' }).error).toBeTruthy();
});
it('购买后商品消失,重复购买不扣钱', () => {
let r = createRun('S');
r.phase = 'reward';
r.money = 100;
r = act(r, { type: 'cashout' });
const it = r.shop[0];
r = act(r, { type: 'buy', uid: it.uid });
const money = r.money;
expect(r.jokers).toHaveLength(1);
const again = dispatch(r, { type: 'buy', uid: it.uid });
expect(again.error).toBeTruthy();
expect(again.state.money).toBe(money);
});
it('满槽购买不扣钱、不移除商品', () => {
let r = createRun('S');
r.phase = 'reward';
r.money = 100;
r = act(r, { type: 'cashout' });
r.jokers = JOKERS.slice(0, 5).map((j, i) => ({ id: j.id, uid: 200 + i, value: 0 }));
const result = dispatch(r, { type: 'buy', uid: r.shop[0].uid });
expect(result.error).toBeTruthy();
expect(result.state).toBe(r);
});
it('免费刷新消耗一次,之后价格从 $5 起', () => {
let r = createRun('S');
r.phase = 'reward';
r.money = 100;
r.freeReroll = true;
r = act(r, { type: 'cashout' });
r = act(r, { type: 'reroll' });
expect(r.money).toBe(100);
expect(r.reroll).toBe(5);
r = act(r, { type: 'reroll' });
expect(r.money).toBe(95);
expect(r.reroll).toBe(6);
});
it('跳盲记录标签,Boss 不能跳过', () => {
let r = act(createRun('S'), { type: 'skip' });
expect(r.freeReroll).toBe(true);
r = act(r, { type: 'skip' });
expect(r.investment).toBe(true);
expect(r.blind).toBe(2);
expect(dispatch(r, { type: 'skip' }).error).toBeTruthy();
});
it('Boss 对手牌/弃牌/出牌的限制', () => {
let r = createRun('S');
r.blind = 2;
r.bosses[0] = 2;
expect(act(r, { type: 'enter' }).hand).toHaveLength(7);
r.bosses[0] = 3;
expect(act(r, { type: 'enter' }).discards).toBe(0);
r.bosses[0] = 4;
r = act(r, { type: 'enter' });
expect(dispatch(r, { type: 'play', ids: [r.hand[0]] }).error).toBeTruthy();
});
it('打完最后次数且未达标失败', () => {
let r = fixture(cards([2]));
r.hands = 1;
r.target = 300;
r = act(r, { type: 'play', ids: [1] });
expect(r.phase).toBe('lost');
});
it('卡包先扣钱,选牌仅能一次', () => {
let r = createRun('S');
r.phase = 'reward';
r.money = 20;
r = act(r, { type: 'cashout' });
r = act(r, { type: 'buy', uid: r.packs[0].uid });
expect(r.phase).toBe('pack');
expect(r.money).toBe(16);
const id = r.packChoices[0].uid;
r = act(r, { type: 'take', uid: id });
expect(r.jokers).toHaveLength(1);
expect(dispatch(r, { type: 'take', uid: id }).error).toBeTruthy();
});
it('完整 24 关流程的状态机通关(测试夹具提升牌型等级)', () => {
let r = createRun('FLOW');
r.levels = r.levels.map(() => 10000);
for (let n = 0; n < 24; n++) {
r = act(r, { type: 'enter' });
const ids =
r.blind === 2 && r.bosses[r.ante - 1] === 4 ? r.hand.slice(0, 5) : r.hand.slice(0, 1);
r = act(r, { type: 'play', ids });
if (n === 23) {
expect(r.phase).toBe('won');
break;
}
expect(r.phase).toBe('reward');
r = act(r, { type: 'cashout' });
r = act(r, { type: 'next' });
}
expect(r.ante).toBe(8);
expect(r.round).toBe(24);
});
it('目标曲线与终局倍率', () => {
const r = createRun('S');
expect(target(r)).toBe(300);
r.ante = 8;
r.blind = 2;
expect(target(r)).toBe(200000);
});
});
describe('消耗品与持久化', () => {
it('删牌后无法凑齐 Boss 所需张数时失败,不留下死局', () => {
let r = fixture(cards([2, 3, 4, 5, 6]));
r.blind = 2;
r.bosses[0] = 4;
r.discards = 0;
r.consumables = [{ uid: 999, kind: 'tarot', ref: 4, price: 3 }];
r = act(r, { type: 'use', uid: 999, ids: [1] });
expect(r.phase).toBe('lost');
});
it('可使用复制牌恢复张数时保留出牌阶段', () => {
let r = fixture(cards([2, 3, 4, 5, 6]));
r.blind = 2;
r.bosses[0] = 4;
r.discards = 0;
r.consumables = [
{ uid: 999, kind: 'tarot', ref: 4, price: 3 },
{ uid: 998, kind: 'tarot', ref: 3, price: 3 },
];
r = act(r, { type: 'use', uid: 999, ids: [1] });
expect(r.phase).toBe('play');
r = act(r, { type: 'use', uid: 998, ids: [2] });
expect(r.hand).toHaveLength(5);
});
it('空牌组进入盲注直接失败', () => {
const r = createRun('EMPTY');
r.cards = [];
expect(act(r, { type: 'enter' }).phase).toBe('lost');
});
it('行星只升级指定牌型,并从库存消失', () => {
let r = act(createRun('S'), { type: 'enter' });
r.consumables = [{ uid: 999, kind: 'planet', ref: 1, price: 3 }];
r = act(r, { type: 'use', uid: 999, ids: [] });
expect(r.levels[1]).toBe(2);
expect(r.consumables).toHaveLength(0);
});
it('塔罗复制产生新实例,保持属性', () => {
let r = fixture(cards([13, 13]));
r.cards[0].seal = true;
r.consumables = [{ uid: 999, kind: 'tarot', ref: 3, price: 3 }];
r = act(r, { type: 'use', uid: 999, ids: [1] });
expect(r.cards).toHaveLength(3);
expect(r.cards[2]).toMatchObject({ rank: 13, seal: true });
expect(r.cards[2].id).not.toBe(1);
expect(new Set(r.hand).size).toBe(3);
});
it('销毁不能清空牌组;非法目标不消耗塔罗', () => {
const r = fixture(cards([14]));
r.consumables = [{ uid: 999, kind: 'tarot', ref: 4, price: 3 }];
expect(dispatch(r, { type: 'use', uid: 999, ids: [1] }).state).toBe(r);
});
it.each([0, 1, 2, 5, 6, 7, 8])('塔罗 %s 修改正确属性', (ref) => {
let r = fixture(cards([14], [2]));
r.consumables = [{ uid: 999, kind: 'tarot', ref, price: 3 }];
r = act(r, { type: 'use', uid: 999, ids: [1] });
if (ref === 0) expect(r.cards[0].suit).toBe(0);
if (ref === 1) expect(r.cards[0].suit).toBe(1);
if (ref === 2) expect(r.cards[0].rank).toBe(2);
if (ref === 5) expect(r.cards[0].enh).toBe('glass');
if (ref === 6) expect(r.cards[0].enh).toBe('steel');
if (ref === 7) expect(r.cards[0]).toMatchObject({ enh: 'bonus', seal: true });
if (ref === 8) expect(r.cards[0].enh).toBe('mult');
});
it('序列化恢复保留抽牌随机状态', () => {
const r = act(createRun('SAVE'), { type: 'enter' }),
copy = JSON.parse(JSON.stringify(r));
expect(validateRun(copy)).toBe(true);
expect(act(r, { type: 'discard', ids: r.hand.slice(0, 3) })).toEqual(
act(copy, { type: 'discard', ids: copy.hand.slice(0, 3) }),
);
});
it('损坏或未知规则的存档被拒绝', () => {
const r = createRun('S');
expect(validateRun({ ...r, rules: 'other' })).toBe(false);
expect(validateRun({ ...r, cards: [{ id: 1, rank: 99, suit: 0 }] })).toBe(false);
expect(validateRun({ ...r, hand: [1, 1] })).toBe(false);
});
});