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

146
tests/balance.test.ts Normal file
View File

@@ -0,0 +1,146 @@
import { expect, it } from 'vitest';
import { createRun, dispatch } from '../src/core/run';
import { scoreHand } from '../src/core/scoring';
import { evaluate } from '../src/core/poker';
import { JOKERS, TAROTS } from '../src/content/catalog';
import { validateRun } from '../src/platform/storage';
import type { Run, Command } from '../src/core/types';
// A bounded, simple greedy player; this checks reachability, not human difficulty.
const weights: Record<string, number> = {
growth: 130,
scholar: 120,
minimal: 110,
double: 100,
chips: 85,
bank: 80,
echo: 65,
pair: 45,
smile: 30,
egg: 25,
last: 50,
hearts: 40,
spades: 35,
clubs: 35,
aces: 30,
even: 35,
odd: 35,
faces: 35,
diamonds: 20,
moon: 15,
triple: 40,
flush: 40,
straight: 30,
wanderer: 5,
};
function best(r: Run) {
let score = -1,
ids: number[] = [];
const visit = (at: number, chosen: number[]) => {
if (chosen.length) {
if (!(r.blind === 2 && r.bosses[r.ante - 1] === 4 && chosen.length !== 5)) {
const s = scoreHand(
structuredClone(r),
chosen.map((id) => r.cards.find((c) => c.id === id)!),
).total;
if (s > score) {
score = s;
ids = [...chosen];
}
}
}
if (chosen.length === 5) return;
for (let i = at; i < r.hand.length; i++) visit(i + 1, [...chosen, r.hand[i]]);
};
visit(0, []);
return { score, ids };
}
function simulate(seed: string) {
let r = createRun(seed),
steps = 0;
const act = (c: Command) => {
const res = dispatch(r, c);
if (res.error) throw Error(res.error);
r = res.state;
expect(validateRun(r)).toBe(true);
};
while (!['won', 'lost'].includes(r.phase) && steps++ < 450) {
if (r.phase === 'blind') {
act({ type: 'enter' });
continue;
}
if (r.phase === 'reward') {
act({ type: 'cashout' });
continue;
}
if (r.phase === 'shop') {
const score = (id: string) => weights[id] || 10;
for (const it of [...r.shop].sort(
(a, b) =>
(b.kind === 'joker' ? score(JOKERS[b.ref].id) : 0) -
(a.kind === 'joker' ? score(JOKERS[a.ref].id) : 0),
)) {
if (it.price > r.money) continue;
if (it.kind === 'joker') {
if (r.jokers.length === 5) {
const low = [...r.jokers].sort((a, b) => score(a.id) - score(b.id))[0];
if (score(JOKERS[it.ref].id) <= score(low.id)) continue;
act({ type: 'sell', uid: low.uid });
}
act({ type: 'buy', uid: it.uid });
} else if (
r.consumables.length < 2 &&
it.kind === 'planet' &&
(it.ref < 3 || r.counts[it.ref] > 2)
)
act({ type: 'buy', uid: it.uid });
}
if (r.money > 12 && r.reroll <= 7) {
act({ type: 'reroll' });
continue;
}
const ids = [...r.jokers]
.sort(
(a, b) =>
(['double', 'minimal', 'last', 'wanderer'].includes(a.id) ? 1 : 0) -
(['double', 'minimal', 'last', 'wanderer'].includes(b.id) ? 1 : 0),
)
.map((j) => j.uid);
act({ type: 'order', zone: 'jokers', ids });
act({ type: 'next' });
continue;
}
if (r.phase === 'play') {
for (const item of [...r.consumables]) {
const ids = item.kind === 'planet' ? [] : r.hand.slice(0, TAROTS[item.ref].max);
act({ type: 'use', uid: item.uid, ids });
}
const b = best(r);
if (r.discards > 0 && b.score < ((r.target - r.score) / r.hands) * 1.3) {
const keep = evaluate(b.ids.map((id) => r.cards.find((c) => c.id === id)!)).scoring.map(
(c) => c.id,
);
const drop = r.hand.filter((id) => !keep.includes(id)).slice(0, 5);
if (drop.length && r.draw.length) {
act({ type: 'discard', ids: drop });
continue;
}
}
act({ type: 'play', ids: b.ids });
}
}
return {
seed,
phase: r.phase,
ante: r.ante,
round: r.round,
best: r.best,
jokers: r.jokers.map((j) => j.id),
};
}
it('固定种子的自然规则对局不会陷入非法状态,并至少可达成一次通关', () => {
const results = Array.from({ length: 16 }, (_, i) => simulate(`VELVET-${i + 1}`));
console.log('Greedy baseline (no altered cards / money / levels):', JSON.stringify(results));
expect(results.every((r) => r.phase === 'won' || r.phase === 'lost')).toBe(true);
expect(results.some((r) => r.phase === 'won')).toBe(true);
}, 60000);

182
tests/browser.mjs Normal file
View File

@@ -0,0 +1,182 @@
import { chromium } from '@playwright/test';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME_PATH ||
(process.platform === 'darwin'
? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
: undefined),
headless: true,
args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on('pageerror', (e) => {
errors.push(String(e));
console.log('PAGE ERROR', String(e));
});
page.on('console', (m) => {
if (m.type() === 'error') console.log('CONSOLE ERROR', m.text());
});
await page.goto('http://127.0.0.1:5173/');
try {
await page.getByRole('button', { name: '接受挑战' }).waitFor({ timeout: 10000 });
} catch (e) {
await page.screenshot({ path: 'artifacts/startup-error.png' });
console.log(await page.locator('body').innerText());
await browser.close();
throw e;
}
await page.screenshot({ path: 'artifacts/blind-selection.png' });
await page.getByRole('button', { name: '接受挑战' }).click();
await page.keyboard.press('1');
await page.keyboard.press('2');
assert.equal(await page.locator('.sr-only button[aria-pressed=true]').count(), 2);
await page.screenshot({ path: 'artifacts/playing.png' });
await page.keyboard.press('d');
assert.equal(await page.locator('.counters strong').nth(1).textContent(), '2');
const before = await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')));
await page.reload();
await page.getByRole('button', { name: '出牌', exact: false }).waitFor();
const after = await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')));
assert.deepEqual(after, before);
await page.mouse.move(474, 665);
await page.mouse.down();
await page.mouse.move(1120, 665, { steps: 15 });
await page.mouse.up();
const moved = await page.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand,
);
if (JSON.stringify(moved) === JSON.stringify(before.hand)) {
await page.screenshot({ path: 'artifacts/drag-error.png' });
console.log('DRAG MODAL', await page.locator('#modal').innerText());
}
assert.notDeepEqual(moved, before.hand);
await page.keyboard.press('1');
await page.keyboard.press('Enter');
await page.waitForFunction(() => !document.querySelector('#scoring-source'));
assert.equal(await page.locator('.counters strong').first().textContent(), '3');
await page.getByRole('button', { name: '牌型图鉴' }).click();
assert.equal(await page.locator('.hand-table tbody tr').count(), 12);
await page.keyboard.press('Escape');
// Deterministic fixtures exercise full UI flows; they do not assert natural win rate.
await page.evaluate(async () => {
const { createRun, dispatch } = await import('/src/core/run.ts');
let r = createRun('BROWSER');
r = dispatch(r, { type: 'enter' }).state;
r.cards.slice(0, 5).forEach((c, i) => {
c.rank = i === 4 ? 9 : 13;
});
r.hand = r.cards.slice(0, 8).map((c) => c.id);
r.draw = r.cards.slice(8).map((c) => c.id);
r.money = 30;
r.target = 300;
r.jokers = [
{ uid: 200, id: 'smile', value: 0 },
{ uid: 201, id: 'pair', value: 0 },
{ uid: 202, id: 'double', value: 0 },
];
r.consumables = [{ uid: 203, kind: 'planet', ref: 1, price: 3 }];
r.nextId = 300;
localStorage.setItem('velvet-joker-run-v1', JSON.stringify(r));
localStorage.setItem('velvet-speed', '4');
});
await page.reload();
await page.getByRole('button', { name: '出牌', exact: false }).waitFor();
await page.mouse.click(421, 240);
await page.getByRole('button', { name: '右移' }).click();
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).jokers[0].id),
'pair',
);
await page.waitForFunction(() => !document.querySelector('.dialog'));
await page.screenshot({ path: 'artifacts/before-consumable.png' });
await page.mouse.click(1232, 228);
try {
await page.getByRole('button', { name: '使用', exact: true }).click({ timeout: 6000 });
} catch (e) {
await page.screenshot({ path: 'artifacts/consumable-error.png' });
console.log('DOM', await page.locator('#modal').innerText());
await browser.close();
throw e;
}
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).levels[1]),
2,
);
await page.keyboard.press('1');
await page.keyboard.press('2');
await page.screenshot({ path: 'artifacts/joker-table.png' });
await page.keyboard.press('Enter');
await page.getByRole('button', { name: '前往商店' }).waitFor({ timeout: 10000 });
const paid = await page.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).money,
);
await page.reload();
await page.getByRole('button', { name: '前往商店' }).click();
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).money),
paid,
);
await page.screenshot({ path: 'artifacts/shop.png' });
await page.locator('[data-action=buy]').first().click();
await page.locator('[data-action=reroll]').click();
await page.locator('[data-action=buy]').last().click();
await page.getByRole('button', { name: '选择此牌' }).first().click();
await page.getByRole('button', { name: '下一盲注' }).click();
await page.getByRole('button', { name: '跳过' }).click();
assert.match(await page.locator('.blind-head').textContent(), /BOSS/);
await page.setViewportSize({ width: 1280, height: 720 });
await page.screenshot({ path: 'artifacts/desktop-1280.png' });
await page.setViewportSize({ width: 932, height: 430 });
await page.screenshot({ path: 'artifacts/mobile-landscape.png' });
await page.getByRole('button', { name: '接受挑战' }).click();
assert.equal(await page.locator('canvas').count(), 1);
assert.deepEqual(errors, []);
const mobile = await browser.newPage({
viewport: { width: 932, height: 430 },
hasTouch: true,
isMobile: true,
});
mobile.on('pageerror', (e) => errors.push(String(e)));
await mobile.goto('http://127.0.0.1:5173/');
await mobile.getByRole('button', { name: '接受挑战' }).click();
const bounds = await mobile.locator('canvas').boundingBox();
const point = (x, y) => ({
x: bounds.x + (x * bounds.width) / 1440,
y: bounds.y + (y * bounds.height) / 900,
});
await mobile.touchscreen.tap(point(474, 665).x, point(474, 665).y);
assert.equal(await mobile.locator('.sr-only button[aria-pressed=true]').count(), 1);
const touch = await mobile.context().newCDPSession(mobile);
await touch.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [point(573, 665)],
});
await mobile.getByRole('dialog').waitFor({ timeout: 4000 });
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
assert.match(await mobile.locator('.dialog').innerText(), /基础筹码/);
await mobile.getByRole('button', { name: '关闭', exact: true }).click();
const mobileBefore = await mobile.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand,
);
await touch.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [point(474, 665)],
});
await touch.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [point(1100, 665)],
});
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
assert.notDeepEqual(
await mobile.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand),
mobileBefore,
);
assert.deepEqual(errors, []);
console.log(
'Browser smoke passed: Canvas selection/drag, play/discard, reload, joker order, consumable, reward, shop, pack, skip, responsive sizes, emulated touch tap/long-press/drag.',
);
await browser.close();

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