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