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

356 lines
13 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 { 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);
});
});