first commit
This commit is contained in:
146
tests/balance.test.ts
Normal file
146
tests/balance.test.ts
Normal 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
182
tests/browser.mjs
Normal 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
355
tests/core.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user