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); }); });