commit 12380c715c4ba1e85cdb99cfdaf7fe5e95e22fa0 Author: richarjiang Date: Mon Sep 21 14:45:24 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf3f731 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ +.DS_Store diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..d2ee69b --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{ "singleQuote": true, "printWidth": 100, "trailingComma": "all" } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9f7b4cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# 项目规则 + +## 目标与工作边界 +- 开发基于 Web 的小丑牌式单人构筑游戏,以规则正确、完整对局和复古牌桌表现为优先级。 +- 用户已授权实施;实施范围以 `docs/implementation-plan.md` 为依据,实际首版规则以 `docs/rules.md` 为准。 +- 附图仅作为视觉参考;图中局内数值不能直接作为默认规则。 +- 已核实规则、社区资料和本项目设计选择应明确区分;不声明尚未验证的原作一致性。 + +## 目录约定 +- 根目录:项目规范、README、依赖和构建配置。 +- `docs/`:方案、规则研究、验收记录;Markdown 文件用小写短横线命名。 +- `src/core/`:纯 TypeScript 规则、状态、随机数与结算,不依赖 DOM、渲染引擎或音频。 +- `src/content/`:带稳定 ID 的牌型、小丑、盲注、消耗品等内容定义。 +- `src/app/`:用户命令、状态流转、存档及表现层协调。 +- `src/render/`:牌桌、卡牌、动效、着色器、输入命中;只能消费核心状态与事件。 +- `src/platform/`:浏览器存储、音频、窗口和生命周期适配。 +- `public/assets/`:运行时美术、字体、音频;来源与许可记录在 `docs/assets.md`,素材导入前建立该文件。 +- `tests/`:规则测试、组合用例、对局与浏览器验收;禁止用生产实现自身生成唯一预期结果。 +- `artifacts/`:可复查的截图和验收输出;临时文件使用系统临时目录,阶段结束清理无用输出。 +- 不提前创建空目录;新增职责或改变边界先修改此文档。若后续增加 CLAUDE.md,须与此规范保持一致。 + +## 实现约束 +- 所有 shell 命令遵守 `/Users/richard/.codex/RTK.md`,通过 `rtk` 或 `rtk proxy` 执行。 +- 计分由核心引擎一次确定并输出事件序列;动画不负责修改得分或发放奖励。 +- 随机数可设种子、可保存状态;商店、抽牌和效果使用独立随机流。 +- 实例 ID 与内容 ID 分离;允许重复点数、花色和重复内容实例。 +- 禁止把小丑效果、Boss 规则写进卡牌绘制或点击处理函数。 +- 使用自制或可使用的素材;截图不能充当交互界面或直接拆作牌面资源。 +- 先做规则和完整流程的针对性验证,再验证画面、触屏、性能与兼容性;如实记录未验证平台。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e85ea78 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# 天鹅绒小丑 · Velvet Joker + +一款可在浏览器游玩的单人扑克构筑游戏。使用 TypeScript + PixiJS 8,在 Canvas / WebGL 中绘制与操作卡牌;界面文字和按钮使用 HTML,保障中文清晰和键盘操作。 + +## 启动 + +需要 Node.js 22(已验证 22.15.1)。 + +```sh +npm ci +npm run dev +``` + +打开终端给出的本地地址。开发服务器默认端口 5173;仅本机访问可使用 `npm run dev -- --host 127.0.0.1`。 + +```sh +npm test # 规则测试 + 16 个固定种子的自然对局检查 +npm run build # TypeScript 检查与生产构建 +npm run preview # 预览 dist 构建产物 +``` + +`dist/` 可部署到静态站点。游戏不需要账号、API 或服务器数据;无外部字体、图片、音频请求。首次页面加载仍需静态服务器,未实现 Service Worker 离线缓存。 + +## 操作 + +- 点击卡牌选择,最多 5 张;拖动手牌或小丑调整顺序。 +- `1–8` 选牌,`Enter` 出牌,`D` 弃牌,`Esc` 取消选择或关闭弹窗。 +- 小丑从左向右触发;点击查看效果、调整顺序或出售。 +- 右上角消耗品点击使用;塔罗需先选择目标手牌。右键或长按手牌查看信息。 +- 过关后进入商店,购买、刷新、开包,随后挑战下一盲注。 +- 设置中可调整音效、CRT 效果、结算速度,或输入种子开始新局。 +- 成功操作后自动保存;刷新会恢复已提交状态,不重播奖励。浏览器不同来源/端口的存档彼此独立。 + +## 首版内容 + +24 张独立小丑、12 类牌型及行星升级、9 种塔罗、4 类强化和红色蜡封、6 种常规 Boss、1 种终局 Boss、8 Ante / 24 个盲注。支持跳盲奖励、购买出售、开包、利息、胜利失败和本地续局。 + +规则参考 Balatro 的基础机制,但卡池、美术与部分规则为本作独立设计。具体数值、触发顺序和差异见 [规则文档](docs/rules.md),不声称完整复刻原作。 + +## 代码结构 + +- `src/core/`:纯规则、牌型、计分事件、随机数、事务状态机。 +- `src/content/`:卡牌、牌型和 Boss 内容。 +- `src/app/`:界面、命令协调、结算动画和样式。 +- `src/render/`:PixiJS 牌桌、程序卡面、拖拽与命中检测。 +- `src/platform/`:存档校验与 Web Audio 音效。 +- `tests/`:手算规则用例、自然规则模拟、浏览器交互。 + +增加小丑需要同时定义内容、计分处理、金样测试,并更新规则文档。动画不可反向修改计分状态。 + +## 浏览器验证 + +先启动开发服务器,再运行 `npm run test:browser`。脚本默认使用 macOS 系统 Chrome 的独立临时环境,其他环境可设置 `CHROME_PATH` 指向 Chrome;也可使用安装了 Playwright Chromium 的环境。 + +测试覆盖真实 Canvas 点击/拖动、出牌/弃牌、刷新恢复、小丑重排、消耗品、商店/卡包和跳盲。测试中的定向 UI 夹具用于覆盖指定分支,不是自然通关证据;自然通关由 `tests/balance.test.ts` 另行验证。截图写入 `artifacts/`。 + +## 当前边界 + +- 需要 WebGL;不可用时显示明确提示。桌面横屏优先,手机横屏可玩,竖屏提示旋转。 +- 已验证 Chrome 软件 WebGL 与多个视口;Safari、真实手机触摸和硬件帧率尚未验收。 +- 固定种子贪心玩家能正常胜败,不代表全部流派已经平衡;目前对子成长流更容易形成稳定组合。 +- 不包含完整原作卡池、挑战/解锁系统、云存档和无尽大数模式。 + +详见 [验收记录](docs/verification.md) 和 [资源来源](docs/assets.md)。 diff --git a/artifacts/blind-selection.png b/artifacts/blind-selection.png new file mode 100644 index 0000000..7bfbd9e Binary files /dev/null and b/artifacts/blind-selection.png differ diff --git a/artifacts/desktop-1280.png b/artifacts/desktop-1280.png new file mode 100644 index 0000000..3a4ea1a Binary files /dev/null and b/artifacts/desktop-1280.png differ diff --git a/artifacts/joker-table.png b/artifacts/joker-table.png new file mode 100644 index 0000000..dac3bd6 Binary files /dev/null and b/artifacts/joker-table.png differ diff --git a/artifacts/mobile-landscape.png b/artifacts/mobile-landscape.png new file mode 100644 index 0000000..c28cdea Binary files /dev/null and b/artifacts/mobile-landscape.png differ diff --git a/artifacts/playing.png b/artifacts/playing.png new file mode 100644 index 0000000..88aaa42 Binary files /dev/null and b/artifacts/playing.png differ diff --git a/artifacts/shop.png b/artifacts/shop.png new file mode 100644 index 0000000..1917184 Binary files /dev/null and b/artifacts/shop.png differ diff --git a/docs/assets.md b/docs/assets.md new file mode 100644 index 0000000..9b79c56 --- /dev/null +++ b/docs/assets.md @@ -0,0 +1,6 @@ +# 资源约定与来源 + +- 所有扑克花色、牌背、人头牌、小丑、行星与塔罗插画由本项目 Canvas 绘图代码生成,位置 src/render/art.ts,无原作拆包或截图裁切。 +- 音效使用 Web Audio 振荡器合成,位置 src/platform/audio.ts。 +- 字体使用设备自带 Georgia、Courier New 及中文宋体/黑体回退,无外部字体或网络素材依赖。 +- 首版无需 public/assets 文件夹;图像纹理在初始化时生成并复用,资源增加时更新此记录。 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..26e5665 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,130 @@ +# Web 小丑牌实现方案 + +日期:2026-09-21。状态:用户已授权,首版已实现。实际规则及范围以 rules.md 为准,验收情况见 verification.md。 + +## 1. 产品目标与取舍 + +实现可在浏览器直接游玩的单人扑克构筑游戏。重点是构筑选择、计分链与完整对局,其次是贴近参考图的复古牌桌体验。第一版使用中文,桌面横屏优先,兼容平板及手机横屏;离线本地对局,无账号和服务器依赖。 + +推荐 TypeScript + Vite + PixiJS 8,使用 HTML Canvas 承载 WebGL 渲染。Canvas 是呈现载体,不限定必须使用 Canvas 2D API。锁定实际安装的小版本;第一版不依赖实验性 Canvas 2D 回退或 WebGPU。 + +Cocos Creator 可以发布 Web,也适合后续小游戏平台,但目前没有现成场景、Prefab 或跨平台交付需求。此阶段增加编辑器、资源导入和构建链的收益有限。若确认主要目标变成微信/抖音小游戏,再选择 Cocos;独立规则模块可以复用,表现层仍需重做。 + +不把完整原作内容量作为第一版承诺。规则采用可追踪的 Balatro 基础规则参考,牌面和音效制作独立资产。第一版限制卡池,但包含通关、失败和存档,不停留在可点卡牌的展示页。 + +## 2. 图片观察与视觉方案 + +参考图约为 16:9。左侧约四分之一为状态栏,右侧是大面积绿色牌桌;上方放小丑,下方是手牌,中部留给出牌、提示和计分。右上是消耗品,右下是牌堆。 + +- 深墨绿底色与缓慢流动的同色纹理,低对比,不抢牌面。 +- 深灰状态板、偏暖白纸牌、蓝色筹码与红色倍率;金黄用于金钱和奖励。 +- 卡牌有粗边、轻微歪斜、短硬阴影、像素插画和扇形重叠,像桌上的实体纸牌。 +- 英文数字可用像素字体;中文优先保证辨认,使用许可明确的点阵风格字体,避免极细字形。 +- 微弱扫描线、暗角、少量色差作为可关闭的全屏效果。正文和点数保持清晰,不照搬截图的模糊感。 +- 悬停轻抬与倾斜、选中上移、出牌滑向中央、逐张触发弹跳、倍率脉冲、过关盖章。 +- 点击、发牌、加筹码、加倍率、乘倍率使用不同短音效;触发链越长,音高与节奏有递进。 + +设计坐标建议 1440×810,按窗口等比布局并重新分配边距,不拉伸卡牌。小屏横屏收紧状态栏;竖屏先提供明确横屏提示。鼠标悬停信息在触屏改为长按;拖拽设置阈值,避免选牌误触。键盘可选牌、出牌、弃牌和取消选择。 + +美术交付先做一套完整牌桌、扑克花色和人头牌,再制作首批独立小丑插画及牌背。不能仅用 emoji 或文字方框替代最终卡面。最终资源统一调色板、描边和像素密度。 + +## 3. 核心玩法理解 + +基本循环:选择盲注 → 抽牌 → 选 1–5 张出牌或弃牌 → 按顺序触发效果并累计分数 → 达标结算或次数耗尽失败 → 商店构筑 → 下一盲注。 + +每个 Ante 包含小盲、大盲、Boss;传统通关目标为 Ante 8 的最终 Boss。Boss 改变局内约束,小丑改变计分、经济或规则,塔罗修改牌组。这些主结构由官方 FAQ 确认。[S1] + +核心公式是最终筹码乘最终倍率,但不能把所有增益先求和再相乘。社区触发指南明确区分 Boss / 出牌前效果、逐张计分牌、留手效果、小丑独立效果等阶段;同阶段的顺序也是策略的一部分。[S2] + +实现要区分四个概念:选出的牌、判定的主要牌型、牌型包含关系、真正参与计分的牌。例如“包含对子”的效果不应只在最终牌型名称恰好为对子时生效。 + +牌型引擎覆盖高牌、对子、两对、三条、顺子、同花、葫芦、四条、同花顺,以及改牌后可出现的五条、同花葫芦、同花五条。皇家同花顺作为同花顺的展示变体,不独立建升级账本。牌型优先级不随升级后的潜在分数改变。[S3] + +图片中的 6/6 小丑和 10/10 手牌是局内状态,来源无法仅靠图片确定。第一版拟采用基础配置:52 张牌、8 张手牌、4 次出牌、3 次弃牌、5 个小丑槽、2 个消耗品槽;这些值在规则冻结阶段逐项核实,统一配置,不散落在 UI 内。 + +## 4. 首版内容边界 + +以下数量是项目范围建议,不声称是原作内容清单。 + +| 系统 | 第一版目标 | 后续扩展 | +| --- | --- | --- | +| 对局 | 1 个基础牌组、1 个难度、8 个 Ante | 多牌组、多难度、无尽 | +| 扑克牌 | 52 张初始牌、复制/删除/改点数与花色 | 更多特殊牌规则 | +| 牌型 | 12 类计分与升级,皇家同花顺展示变体 | 特殊小丑改变判型 | +| 小丑 | 24 张,覆盖筹码、加倍率、乘倍率、条件、成长、经济、重触发 | 复制效果、复杂规则改写 | +| Boss | 6 种常规机制、1 种终局机制;重复抽取规则明确 | 完整 Boss 池 | +| 消耗品 | 对应牌型的行星、9 种塔罗(新增女皇投放多倍强化) | 幻灵与更多塔罗 | +| 卡牌修饰 | 奖励、多倍、玻璃、钢铁及红色蜡封等分阶段落地 | 全部版本、蜡封与贴纸 | +| 商店 | 购买/出售/刷新、小丑与消耗品、基础补充包 | 更多优惠券与包型 | +| 跳盲 | 基础跳过与有限标签池,Boss 不可跳过 | 完整标签池 | +| 持久化 | 新开局、续局、设置、种子与规则版本 | 收集解锁、成就、挑战 | + +24 张小丑必须支持至少四类有不同决策的构筑,例如对子成长、花色逐张触发、留手钢铁、经济转化。不是把同一加分效果复制 24 次。 + +## 5. 规则与状态架构 + +依赖方向:内容与纯规则 → 应用协调 → 渲染和浏览器适配。引擎输出状态与事件,渲染不得反向拥有规则。 + +核心模块: +- `HandEvaluator`:主要牌型、包含关系、计分牌集合;支持 A 的低位顺子与高位顺子,拒绝跨首尾非法顺子。 +- `ScoringEngine`:按明确阶段计算;返回最终值及逐步 ScoreTrace。 +- `EffectRegistry`:有类型的效果处理器,声明触发阶段、条件、顺序与可重触发性;不建设不必要的通用脚本语言。 +- `RunState`:牌组实例、盲注、回合次数、金钱、槽位、牌型等级与历史。 +- `ShopEngine`:商品池、权重、价格、刷新、购买与出售的原子更新。 +- `SeededRng`:抽牌、商店、概率效果分别使用可保存的随机流。 +- `SaveCodec`:版本号、规则版本、种子、随机状态、内容 ID 和实例状态;损坏存档有明确恢复提示。 + +流程状态:主菜单 → 盲注选择 → 发牌 → 等待操作 → 计分 → 达标结算 / 继续抽牌 / 失败 → 商店 → 下一盲注;最终 Boss 后进入胜利页。 + +以 `dispatch(command, state)` 的事务边界验证操作。计分时禁止重复出牌和不合法重排;结算奖励只发一次。动画使用引擎结果播放,快进、后台恢复和刷新不改变得分。 + +计分事件包含阶段、来源实例、目标、操作类型、前值与后值;例如“某张红桃触发 +3 Mult”。同一份事件支持动画、对局日志和测试诊断。 + +保留三个顺序:出牌顺序、留手顺序、小丑顺序;排序按钮不应无意改变已确认的计分事务。重触发不是递归调用整轮结算,不可重复触发出牌次数或回合奖励。 + +随机预览不消耗真实 RNG,也不承诺随机效果的确切最终分数;默认只显示牌型、基础筹码/倍率和确定触发条件。 + +分数运算集中封装,明确乘数、小数和最终取整规则,规则冻结时核对;不使用 32 位位运算储存得分。首版限制到正常 8 Ante 范围,无尽大数另行设计。 + +## 6. 规则研究交付 + +在编码前补齐 `docs/rules.md`,每条规则记录来源、适用版本、输入例子、预期输出、核实状态与本项目差异。 + +优先核实:牌型基础值和每级增长、包含关系、默认资源、盲注目标曲线、Boss 倍率及抽取条件、奖励/利息/刷新/卖价、补充包选择流程、弃牌补牌与牌库耗尽、失效牌与修饰牌交互、触发及取整顺序。 + +官方 FAQ 只足以确认主循环;社区资料提供细节但并非官方规范。本次部分 Wiki 页面正文抓取失败,搜索摘要不能替代全部边界验证。已找到的资料不等于已完成全量原作复刻研究。对争议交互采用可重复对局证据;无法核实的内容先不进入首版卡池。 + +## 7. 分阶段实施与退出条件 + +| 阶段 | 交付物 | 进入下一阶段的条件 | +| --- | --- | --- | +| A 规则冻结 | 规则表、24 张小丑候选效果清单、固定计分用例 | 首版每个效果均有触发阶段与预期例子 | +| B 纯逻辑 | 牌型、抽弃牌、计分、Boss、商店和对局状态机 | 无界面可运行固定种子流程;计分及状态边界通过 | +| C 纵向切片 | 1 个 Ante、约 6 张代表性小丑、商店、牌桌和音效 | 从开局到 Boss 的真实循环可玩,可看懂每次加分 | +| D 完整首版 | 8 Ante、完整首批卡池、消耗品、跳盲、存档 | 至少可复现一局胜利和一局失败,恢复进度正确 | +| E 打磨验收 | 成套美术、CRT、反馈、触控、性能、构建产物 | 浏览器流程验证和视觉验收完成,限制有记录 | + +完整首版比普通单页卡牌演示复杂,应按以上交付门槛推进,不在规则未冻结时给固定工期承诺。 + +## 8. 验证方法 + +- 单元测试:牌型识别、A 的两种合法顺子、重复实例、主要牌型与包含关系、非计分牌、手牌等级、Boss 限制。 +- 计分金样:手算预期并记录阶段;加倍率/乘倍率换位必须产生正确差异;逐张与留手重触发范围分别验证。 +- 性质测试:无生成/销毁效果时卡牌守恒、实例不重复占区、非法命令不改状态、同种子同命令同结果。 +- 交易测试:重复点击购买/结算不重复扣钱或发奖;槽位满和余额不足不丢商品。 +- 存档测试:计分期间刷新恢复到已提交状态;随机流恢复一致;旧版本和损坏数据处理明确。 +- 浏览器流程:新局、排序、拖拽、出牌、弃牌、购买、跳盲、消耗品、胜败、刷新续局;Playwright 跑关键流程。 +- 视觉检查:1440×810、1280×720、平板和手机横屏;中文不溢出,提示框不出屏,点击命中跟随变换后牌面。 +- 性能目标:约定桌面设备上的正常场景目标 60 FPS;低性能设备关闭后处理并降低渲染分辨率,结果以实测为准。 +- 平衡测试:固定种子与脚本策略检查明显死局、数值异常和商店供给;自动策略胜率不等于人类体验,仍需人工玩不同流派。 + +## 9. 来源 + +- [S1 官方 FAQ](https://www.playbalatro.com/faq):主循环、Ante 8、Joker 与塔罗的职责。 +- [S2 社区触发顺序指南](https://balatrogame.fandom.com/wiki/Guide:_Activation_Sequence):计分阶段与顺序,非官方资料。 +- [S3 社区牌型页](https://balatrogame.fandom.com/wiki/Poker_Hands):本次以搜索摘要取得部分信息,正文未成功获取;完整表待核实。 +- [S4 社区盲注页](https://balatrogame.fandom.com/wiki/Blinds_and_Antes):本次仅获取搜索摘要,完整数值待核实。 +- [S5 PixiJS Application](https://pixijs.com/8.x/guides/components/application):Canvas、渲染器与应用初始化。 +- [S6 Cocos Creator Web 发布](https://docs.cocos.com/creator/3.8/manual/en/editor/publish/publish-web.html):Cocos 的 Web 构建支持。 + +以上链接访问日期为 2026-09-21。依赖版本及社区规则在实施时固定,不能把网页更新自动当作项目规则变更。 diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..e2a77d8 --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,45 @@ +# 首版冻结规则 · velvet-v1 + +本作是独立内容的 Balatro 式构筑游戏,不声称完整复刻原作。基础牌型、升级数值和白注目标参考社区资料;以下具体交互以本文件及测试为准。 + +## 基础与牌型 +- 52 张普通牌,初始 $4,手牌 8、出牌 4、弃牌 3、小丑槽 5、消耗品槽 2。每次选 1–5 张。每轮重新洗整副存活牌;轮内已打出/弃掉的牌不回抽牌堆,牌库耗尽不重洗,仍可打剩余手牌。手牌和牌库都空且未达标则失败。 +- 主要牌型按强弱取最高类别,不以潜在分数选类别。只有组成牌型的牌计分。A 可作 A2345 或 TJQKA,不可绕接。相同牌面可有不同实例。 +- 高牌 5×1 (+10/+1),对子 10×2 (+15/+1),两对 20×2 (+20/+1),三条 30×3 (+20/+2),顺子 30×4 (+30/+3),同花 35×4 (+15/+2),葫芦 40×4 (+25/+2),四条 60×7 (+30/+3),同花顺 100×8 (+40/+4),五条 120×12 (+35/+3),同花葫芦 140×14 (+40/+4),同花五条 160×16 (+50/+3)。括号是升级增量。 +- 数字牌按点数,J/Q/K=10,A=11。包含对子定义为至少两个同点数,包含三条为至少三个;两对必须有两个不同的对子点数。 + +## 结算顺序 +1. 判型、升级值、Boss 基础值修正,成长小丑更新。 +2. 按出牌顺序处理计分牌:点数、强化、逐张小丑;红封和回声分别增加一次完整逐张触发,不递归增生触发。 +3. 留手牌从左到右处理钢铁,再处理月后。 +4. 小丑从左到右处理独立筹码/加倍率/乘倍率。最终向下取整筹码×倍率。 +5. 玻璃牌仅在本次参与计分后各有 1/4 概率销毁一次;轮末经济与奖励各结算一次。 +- 示例:K K 7 为对子,(10+10+10)×2=60;+4 倍率之后 ×2 得 360,交换这两个效果得 240。 +- 基础奖励牌 +30 筹码,多倍牌 +4 倍率,玻璃牌 ×2,钢铁留手 ×1.5;只能有一种强化,红封可叠加。失效牌保留判型但不触发点数/强化/逐张效果。 + +## 关卡与经济 +- 8 Ante 基础目标为 300/800/2000/5000/11000/20000/35000/50000,小盲 ×1,大盲 ×1.5,Boss 通常 ×2;终局 ×4。 +- 六种常规 Boss:红桃失效、黑桃失效、手牌上限 -1、无弃牌、必须出 5 张、牌型基础筹码/倍率减半(向上取整);Ante 1–6 不重复,Ante 7 重用随机一种,8 为终局。 +- 奖励依次 $3/$4/$5,加剩余出牌次数,加结算前余额利息 floor(金钱/5) 上限 $5,加经济小丑收入。 +- 商店 3 张商品、2 个包;刷新起价 $5,每次加 $1,只刷新商品。卖价 floor(原价/2),最低 $1。已持有小丑不再次出现在普通商店。 +- 购买必须同时满足余额/槽位/当前阶段;失败不修改任何状态。 +- 自定义跳盲标签:小盲跳过后下次商店刷新免费一次;大盲跳过后击败 Boss 获得 $15。Boss 不可跳过。 +- 本作小丑均为独立命名与平衡设计,具体效果文本与实现统一定义于 src/content/catalog.ts。 +- 行星:每种牌型升级 1 级,特殊牌型出现前不入随机商品池。 +- 九种塔罗:最多 3 张转红桃;最多 3 张转黑桃;最多 2 张点数 +1(A→2);复制 1 张;销毁最多 2 张(牌组保留至少 1 张);强化 1 张玻璃;强化 1 张钢铁;强化 1 张奖励并加红封;最多 2 张强化为多倍牌。相比初始方案增加女皇,确保多倍强化在正常游戏中可获取。 +- 消耗品仅可在等待出牌时使用;行星无需选牌,其余需按说明选牌。包内选 1 张加入库存,可以放弃;不替代已满槽位。 +- 牌组耗尽或删牌后,如果无法满足最低出牌张数,且剩余弃牌/抽牌与复制消耗品都无法恢复到所需张数,则立即失败,避免卡在无法操作的局面。 + +## 存档与随机 +- 每个成功命令保存已提交状态,动画只播放日志;刷新不会再次结算。 +- 种子分别派生抽牌、商店、效果随机流,动画和预览不消耗随机数。 +- 初始存档版本 1,不兼容或损坏存档需提示并允许新开局;不静默覆盖。 + +## 来源与适用范围 +- https://www.playbalatro.com/faq (主循环,已查阅) +- https://balatrogame.fandom.com/wiki/Guide:_Activation_Sequence (社区触发阶段,已查阅;首版不含完整原作例外) +- https://balatrogame.fandom.com/wiki/Poker_Hands (牌型资料,部分经搜索摘录;本表作为本作明确规则) +- https://balatrogame.fandom.com/wiki/Planet_Cards (升级资料;本表作为本作明确规则) +- https://balatrogame.fandom.com/wiki/Blinds_and_Antes (白注基础曲线,已取得表格) + +检索日期 2026-09-21。经济、小丑、Boss 池、跳过奖励、商店供给及塔罗范围均按上述首版规则实现,不宣称逐项与原作一致。 diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..755d1ac --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,58 @@ +# 首版验收记录 + +日期:2026-09-21。规则版本:velvet-v1。 + +## 已完成 + +- TypeScript 静态检查与 Vite 生产构建通过。 +- 80 个规则/状态/存档用例通过,加 1 个固定种子对局测试,共 81 个测试。 +- 牌型测试覆盖 12 类牌型、A 高低顺子与非法绕接、牌型包含关系、计分牌顺序。 +- 计分金样覆盖加倍率与乘倍率顺序、红封与回声叠加、留手钢铁和月后、Boss 失效与钝刃、成长与牌型升级、经济先后顺序。 +- 事务覆盖重复购买、重复领奖、槽位已满、非法目标、卡包选择、跳盲标签、牌库守恒、无法出牌的死局、存档校验与恢复。 +- Chrome 浏览器交互通过:Canvas 选牌、拖拽、出牌、弃牌、刷新恢复、图鉴、小丑调序、行星使用、领奖、购买、刷新、开包、跳盲。 +- Chrome 模拟触屏通过:点击选牌、长按查看手牌、触摸拖动排序。 +- 1440×900、1280×720、932×430 视口截图检查;左侧状态与操作区域无溢出。 +- 浏览器测试最终运行无页面脚本错误。 + +## 自然规则流程检查 + +使用 `tests/balance.test.ts` 中的有限贪心策略,运行种子 VELVET-1 到 VELVET-16。没有修改金钱、牌面、次数或等级,仅通过正常命令操作。 + +- 11 局通关,5 局失败。 +- VELVET-1 完成全部 24 个盲注,最高单手 72,000。 +- VELVET-5 在 Ante 4 失败;VELVET-12 到达最终 Boss 后失败。 +- 每次成功命令后检查存档结构和实例唯一性。 + +这只证明正常规则下有可复现的胜败流程,不是人类胜率估计。贪心策略偏好对子与成长组合,不能据此宣称留手、同花等所有流派已平衡。 + +## 界面截图 + +- `artifacts/blind-selection.png`:正常新局盲注选择。 +- `artifacts/playing.png`:正常新局出牌界面。 +- `artifacts/joker-table.png`:固定 UI 夹具,用来验证小丑重排和行星效果;牌面经过测试设置,不是自然进程截图。 +- `artifacts/shop.png`:该 UI 夹具的商店流程。 +- `artifacts/desktop-1280.png` / `artifacts/mobile-landscape.png`:相同夹具的尺寸验收。 + +所有截图均来自实际运行的浏览器,没有绘图替代或后期修改。 + +## 环境与限制 + +- Node.js 22.15.1;PixiJS 8.6.6;Vite 6.1.0;TypeScript 5.7.3。 +- 浏览器自动化使用 macOS 系统 Chrome 的独立临时上下文和 SwiftShader 软件 WebGL,不读取个人浏览器资料。 +- Headless Chrome 默认环境未提供可用 WebGL,测试显式启用软件 WebGL;产品在 WebGL 不可用时显示启动提示并保留已有存档。 +- 未测真实手机、Safari、Edge 或硬件帧率;60 FPS 仍为目标,不作为已验证结论。 +- 手机横屏使用等比缩放;文字和按钮比桌面更紧凑。竖屏提示旋转。 +- 本地存档按浏览器来源区分;未实现云同步或安装型离线缓存。 +- 首版未实现原作全部卡池、挑战解锁、无尽大数模式。 + +## 运行命令 + +```sh +npm run build +npm test +npm run format:check +# 先启动 npm run dev,再运行: +npm run test:browser +``` + +浏览器测试采用专用临时上下文,不修改实际预览面板里的用户对局。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..a2a6520 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + 天鹅绒小丑 · VELVET JOKER + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..101c3c7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1691 @@ +{ + "name": "velvet-joker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "velvet-joker", + "version": "1.0.0", + "dependencies": { + "pixi.js": "8.6.6" + }, + "devDependencies": { + "@playwright/test": "1.51.1", + "prettier": "3.5.3", + "typescript": "5.7.3", + "vite": "6.1.0", + "vitest": "3.0.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", + "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", + "license": "MIT" + }, + "node_modules/@types/earcut": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", + "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", + "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", + "chai": "^5.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", + "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", + "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.0.5", + "pathe": "^2.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", + "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.5", + "magic-string": "^0.30.17", + "pathe": "^2.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", + "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", + "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.5", + "loupe": "^3.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.74", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", + "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pixi.js": { + "version": "8.6.6", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.6.6.tgz", + "integrity": "sha512-o5pw7G2yuIrnBx0G4npBlmFp+XGNcapI/Ufs62rRj/4XKxc1Zo74YJr/BtEXcXTraTKd+pQvYOLvnfxRjxBMvQ==", + "license": "MIT", + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/css-font-loading-module": "^0.0.12", + "@types/earcut": "^2.1.4", + "@webgpu/types": "^0.1.40", + "@xmldom/xmldom": "^0.8.10", + "earcut": "^2.2.4", + "eventemitter3": "^5.0.1", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.1.2" + } + }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.5.1", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", + "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.2", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", + "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.0.5", + "@vitest/mocker": "3.0.5", + "@vitest/pretty-format": "^3.0.5", + "@vitest/runner": "3.0.5", + "@vitest/snapshot": "3.0.5", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", + "chai": "^5.1.2", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.5", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.5", + "@vitest/ui": "3.0.5", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..644733c --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "velvet-joker", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc --noEmit && vite build", + "test": "vitest run", + "test:browser": "node tests/browser.mjs", + "format": "prettier --write src tests *.json *.ts index.html", + "format:check": "prettier --check src tests *.json *.ts index.html", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "pixi.js": "8.6.6" + }, + "devDependencies": { + "@playwright/test": "1.51.1", + "prettier": "3.5.3", + "typescript": "5.7.3", + "vite": "6.1.0", + "vitest": "3.0.5" + } +} diff --git a/src/app/main.ts b/src/app/main.ts new file mode 100644 index 0000000..e0876f8 --- /dev/null +++ b/src/app/main.ts @@ -0,0 +1,451 @@ +import './style.css'; +import { Board } from '../render/board'; +import { createRun, dispatch, target, cardName } from '../core/run'; +import { evaluate } from '../core/poker'; +import { HANDS, JOKERS, TAROTS, BOSSES, jokerDef } from '../content/catalog'; +import { load, save, readSetting, writeSetting } from '../platform/storage'; +import { sound } from '../platform/audio'; +import type { Command, Item, Run, Score } from '../core/types'; +const root = document.querySelector('#app')!; +root.innerHTML = `
↻横过来,牌桌更宽敞请横屏游玩天鹅绒小丑
`; +const ui = document.querySelector('#ui')!, + modal = document.querySelector('#modal')!, + tooltip = document.querySelector('#tooltip')!, + game = document.querySelector('#game')!; +const loaded = load(); +let run: Run = loaded.run || createRun(makeSeed()), + selected: number[] = [], + busy = false, + modalOpen = false, + speed = [1, 2, 4].includes(Number(readSetting('velvet-speed'))) + ? Number(readSetting('velvet-speed')) + : 1; +let crt = readSetting('velvet-crt') !== 'off'; +game.classList.toggle('no-crt', !crt); +const board = new Board(); +let toastTimer: ReturnType; +function makeSeed() { + return crypto.getRandomValues(new Uint32Array(1))[0].toString(36).toUpperCase().slice(0, 8); +} +const esc = (s: unknown) => + String(s).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!, + ); +const fmt = (n: number) => + n >= 1e9 ? n.toExponential(2) : n.toLocaleString('en-US', { maximumFractionDigits: 1 }); +const btn = (label: string, action: string, cls = '', disabled = false, extra = '') => + ``; +function resize() { + const scale = Math.min(innerWidth / 1440, innerHeight / 900); + game.style.transform = `scale(${scale})`; + document.querySelector('#fit')!.style.cssText = + `width:${1440 * scale}px;height:${900 * scale}px`; +} +window.addEventListener('resize', resize); +resize(); +function toast(s: string) { + const e = document.querySelector('#toast')!; + e.textContent = s; + e.classList.add('visible'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => e.classList.remove('visible'), 3400); +} +function persist() { + if (!save(run)) toast('浏览器存储不可用:当前对局尚未保存'); +} +function itemName(i: Item) { + return i.kind === 'joker' + ? JOKERS[i.ref].name + : i.kind === 'planet' + ? HANDS[i.ref][5] + : i.kind === 'tarot' + ? TAROTS[i.ref].name + : i.ref === 0 + ? '小丑补充包' + : '天象补充包'; +} +function itemDesc(i: Item) { + return i.kind === 'joker' + ? JOKERS[i.ref].desc + : i.kind === 'planet' + ? `${HANDS[i.ref][0]}升级 1 级:+${HANDS[i.ref][3]} 筹码、+${HANDS[i.ref][4]} 倍率。` + : i.kind === 'tarot' + ? TAROTS[i.ref].desc + : '打开 3 张牌,选择 1 张加入库存;可以放弃。'; +} +function blindName(b = run.blind) { + return b === 0 ? '小盲注' : b === 1 ? '大盲注' : BOSSES[run.bosses[run.ante - 1]].name; +} +function preview() { + if (!selected.length) return null; + const cards = selected.map((id) => run.cards.find((c) => c.id === id)).filter((c) => !!c); + return cards.length ? evaluate(cards) : null; +} +function render() { + selected = selected.filter((id) => run.hand.includes(id)); + const p = preview(), + h = p ? HANDS[p.hand] : null, + lv = p ? run.levels[p.hand] - 1 : 0; + const chips = h ? h[1] + h[3] * lv : run.last?.chips || 0, + mult = h ? h[2] + h[4] * lv : run.last?.mult || 0; + const boss = run.blind === 2 ? BOSSES[run.bosses[run.ante - 1]] : null; + ui.innerHTML = `
♠VELVET JOKER天 鹅 绒 小 丑
✦ A LITTLE LUCK. A LITTLE MADNESS. ✦
+ +
${run.jokers.length ? '拖动小丑调整触发顺序 · 点击查看详情' : '等待你的第一位小丑 · 在商店组建阵容'}
+
${center()}
+ ${run.phase === 'play' ? `
${btn('出牌 ↵', 'play', 'blue-button', busy || !selected.length)}
整理手牌
${btn('弃牌 D', 'discard', 'red-button', busy || !selected.length || run.discards === 0)}
` : ''} +
${busy ? '正在结算' : '本地对局 · 自动保存'} 种子 ${esc(run.seed)}拖动排序 · 1–8 选牌 · ENTER 出牌 · D 弃牌
+
${run.phase === 'play' ? run.hand.map((id) => ``).join('') : ''}
`; + board.draw(run, selected, busy); +} +function center() { + if (busy) + return `
THE CARDS ARE SPEAKING

开始计分

好戏,现在上演。

`; + if (run.phase === 'play') + return `
VJ
MAKE YOUR NEXT MOVE

${run.last ? `上手得分 ${fmt(run.last.total)}` : '好牌,也需要一点巧思。'}

${selected.length ? '已选 ' + selected.length + ' 张 · 可以拖动调整计分顺序' : '选择 1–5 张牌出牌,或弃牌寻找更好的组合'}

`; + if (run.phase === 'blind') + return `
ANTE ${String(run.ante).padStart(2, '0')} / 08

下一场,好戏。

选择挑战,或留待下一次机会。
${[0, 1, 2].map((b) => `
${b === 0 ? 'SMALL BLIND' : b === 1 ? 'BIG BLIND' : 'BOSS BLIND'}${b < run.blind ? '已结束' : b === run.blind ? '当前挑战' : '待解锁'}
${b === 0 ? '♣' : b === 1 ? '♠' : '♛'}

${esc(blindName(b))}

${fmt(target(run, b))}分

${b === 2 ? esc(BOSSES[run.bosses[run.ante - 1]].desc) : '奖励 ' + '$'.repeat(3 + b) + ' · 剩余出牌奖励'}

${b === run.blind ? btn('接受挑战 →', 'enter', 'gold-button') : '
' + (b < run.blind ? '— 已经过场 —' : '— 等待入场 —') + '
'}${b === run.blind && b !== 2 ? `` : ''}
`).join('')}
`; + if (run.phase === 'shop' || run.phase === 'pack') { + const pack = run.phase === 'pack', + items = pack ? run.packChoices : run.shop; + return `
${pack ? 'A LITTLE SURPRISE' : 'THE CURIOSITY SHOP'}

${pack ? '挑一张,带走好运。' : '好戏背后的秘密。'}

${pack ? '选择 1 张 · 需要空闲槽位' : '小丑从左向右触发 · 排列也是策略'}
${items.map((i, n) => `
${esc(itemName(i))}${i.kind === 'joker' ? JOKERS[i.ref].rarity : i.kind === 'planet' ? '牌型升级' : '塔罗牌'}${btn(pack ? '选择此牌' : `购买 $${i.price}`, pack ? 'take' : 'buy', 'shop-buy', !pack && run.money < i.price, `data-id="${i.uid}"`)}
`).join('')}${pack ? '' : run.packs.map((i, n) => `
${esc(itemName(i))}3 选 1${btn(`打开 $${i.price}`, 'buy', 'shop-buy', run.money < i.price, `data-id="${i.uid}"`)}
`).join('')}
${pack ? btn('放弃本次选择', 'closePack', 'quiet-button') : btn(`刷新商品 ${run.freeReroll ? '免费' : '$' + run.reroll}`, 'reroll', 'quiet-button', !run.freeReroll && run.money < run.reroll)}

${pack ? '已打开的卡包不会退还费用' : '攒下每 $5,过关赚取 $1 利息,最多 $5'}

${pack ? '' : btn('下一盲注 →', 'next', 'gold-button')}
`; + } + if (run.phase === 'reward') + return `
A ROUND WELL PLAYED

漂亮,过关。

本轮收获 $${run.reward!.total}

盲注奖励 $${run.reward!.base}剩余出牌 $${run.reward!.hands}利息收入 $${run.reward!.interest}${run.reward!.bonus ? `额外收入 $${run.reward!.bonus}` : ''}
${btn('前往商店 →', 'cashout', 'gold-button')}
`; + return `
${run.phase === 'won' ? 'THE FINAL CURTAIN' : 'EVERY END IS A NEW HAND'}

${run.phase === 'won' ? '今夜,你是主角。' : '好戏,暂时落幕。'}

${run.phase === 'won' ? '你征服了全部 8 个底注。' : '没有足够的分数越过这次盲注。'}

最高单手${fmt(run.best)}累计得分${fmt(run.totalScore)}抵达底注${run.ante} / 8
${btn('再开一局 →', 'new', 'gold-button')}
`; +} +async function command(c: Command) { + if (busy) return; + const previous = run, + result = dispatch(run, c); + if (result.error) { + toast(result.error); + sound.play(); + return; + } + run = result.state; + persist(); + closeModal(); + if (c.type === 'play') { + selected = []; + busy = true; + render(); + const visual = { ...run, phase: 'play' as const }; + board.draw(visual, [], true); + board.showPlayed(run.last!.played); + await animateScore(run.last!, previous.score); + busy = false; + board.clearHighlight(); + render(); + if (run.phase === 'reward' || run.phase === 'won') sound.play('win'); + } else { + if (['discard', 'use', 'enter'].includes(c.type)) selected = []; + sound.play(c.type === 'enter' || c.type === 'discard' ? 'card' : 'click'); + render(); + if (c.type === 'use') toast('消耗品已生效'); + } +} +async function animateScore(s: Score, prior: number) { + document.querySelector('#round-score')!.textContent = fmt(prior); + const delay = Math.min(260, 3500 / Math.max(1, s.trace.length)) / speed; + for (let i = 0; i < s.trace.length; i++) { + const t = s.trace[i]; + document.querySelector('#chips')!.textContent = fmt(t.chips); + document.querySelector('#mult')!.textContent = fmt(t.mult); + document.querySelector('#hand-title')!.textContent = s.name; + document.querySelector('#scoring-source')!.textContent = t.source; + document.querySelector('#scoring-label')!.textContent = t.label; + board.highlight(t.cardId, t.jokerUid); + sound.play('score', i); + await new Promise((r) => setTimeout(r, delay)); + } + document.querySelector('#round-score')!.textContent = fmt(prior + s.total); + document.querySelector('#scoring-source')!.textContent = '+' + fmt(s.total); + document.querySelector('#scoring-label')!.textContent = '筹码落定'; + await new Promise((r) => setTimeout(r, 650 / speed)); +} +function select(id: number) { + if (busy || run.phase !== 'play') return; + if (selected.includes(id)) selected = selected.filter((x) => x !== id); + else if (selected.length < 5) selected.push(id); + else { + toast('每次最多选择 5 张牌'); + return; + } + selected = run.hand.filter((id) => selected.includes(id)); + sound.play('card'); + render(); +} +let focusBefore: HTMLElement | null = null; +function openModal(title: string, body: string, wide = false) { + focusBefore = document.activeElement as HTMLElement; + modalOpen = true; + tooltip.hidden = true; + modal.innerHTML = ``; + modal.querySelector('button')?.focus(); +} +function closeModal() { + if (!modalOpen) return; + modalOpen = false; + modal.innerHTML = ''; + focusBefore?.focus(); +} +function inspect(kind: 'joker' | 'item' | 'card', id: number) { + if (busy) return; + if (kind === 'joker') { + const j = run.jokers.find((j) => j.uid === id); + if (!j) return; + const d = jokerDef(j.id), + index = run.jokers.indexOf(j); + openModal( + d.name, + `
${d.en} · ${d.rarity}

${d.desc}

${j.id === 'growth' ? `

当前积累:+${j.value} 倍率

` : ''}
${btn('← 左移', 'move-left', 'quiet-button', index === 0, `data-id="${id}"`)}${btn('右移 →', 'move-right', 'quiet-button', index === run.jokers.length - 1, `data-id="${id}"`)}${btn(`出售 $${Math.floor(d.price / 2)}`, 'sell', 'red-button', !['play', 'shop', 'blind'].includes(run.phase), `data-id="${id}"`)}
`, + ); + return; + } + if (kind === 'card') { + const c = run.cards.find((c) => c.id === id)!; + openModal( + cardName(c), + `

基础筹码 ${c.rank === 14 ? 11 : Math.min(10, c.rank)}${c.enh ? ' · 强化 ' + c.enh : ''}${c.seal ? ' · 红色蜡封' : ''}

`, + ); + return; + } + const inv = run.consumables.find((i) => i.uid === id), + it = inv || [...run.shop, ...run.packs, ...run.packChoices].find((i) => i.uid === id); + if (!it) return; + openModal( + itemName(it), + `

${esc(itemDesc(it))}

${inv ? `

${it.kind === 'planet' ? '无需选牌。' : `先选中目标手牌;当前已选 ${selected.length} 张。`}消耗品在出牌阶段使用。

${btn('使用', 'use', 'gold-button', run.phase !== 'play', `data-id="${id}"`)}${btn('出售 $1', 'sell', 'quiet-button', !['play', 'shop', 'blind'].includes(run.phase), `data-id="${id}"`)}
` : `
${btn(run.phase === 'pack' ? '选择此牌' : `购买 $${it.price}`, run.phase === 'pack' ? 'take' : 'buy', 'gold-button', run.phase !== 'pack' && run.money < it.price, `data-id="${id}"`)}
`}`, + ); +} +function collection() { + openModal( + '牌型图鉴', + `

按牌型优先级判定;只有组成牌型的牌参与计分。括号为每次升级增量。

${[ + ...HANDS, + ] + .map( + (h, i) => + ``, + ) + .reverse() + .join('')}
牌型等级筹码倍率本局次数
${h[0]}Lv.${run.levels[i]}${h[1] + (run.levels[i] - 1) * h[3]} (+${h[3]})${h[2] + (run.levels[i] - 1) * h[4]} (+${h[4]})${run.counts[i]}
`, + true, + ); +} +function settings() { + openModal( + '牌桌设置', + `
复古屏幕扫描线与暗角${btn(crt ? '开启' : '关闭', 'crt', 'quiet-button')}
游戏音效发牌与递进计分反馈${btn(sound.enabled ? '开启' : '关闭', 'sound', 'quiet-button')}
结算速度${btn(speed + '×', 'speed', 'quiet-button')}
${btn('新开一局', 'new', 'red-button')}${btn('继续游戏', 'close', 'gold-button')}
`, + ); +} +function newDialog() { + openModal( + '新的一局,新的可能。', + `

开始后将替换当前对局。输入相同种子可重现初始牌序与商品。

${btn('取消', 'close', 'quiet-button')}${btn('开始新对局 →', 'confirm-new', 'gold-button')}
`, + ); +} +root.addEventListener('click', (e) => { + const el = (e.target as HTMLElement).closest('[data-action]'); + if (!el) return; + e.preventDefault(); + if (el.hasAttribute('disabled')) return; + const a = el.dataset.action!, + id = Number(el.dataset.id); + if (busy && !['sound'].includes(a)) { + toast('正在结算,请稍候'); + return; + } + if (['enter', 'skip', 'cashout', 'next', 'reroll', 'closePack'].includes(a)) { + void command({ type: a } as Command); + return; + } + if (['buy', 'sell', 'take'].includes(a)) { + void command({ type: a, uid: id } as Command); + return; + } + if (a === 'play' || a === 'discard') { + void command({ type: a, ids: [...selected] }); + return; + } + if (a === 'use') { + void command({ type: 'use', uid: id, ids: [...selected] }); + return; + } + if (a === 'select') { + select(id); + return; + } + if (a === 'sort-rank' || a === 'sort-suit') { + const ids = [...run.hand].sort((a, b) => { + const x = run.cards.find((c) => c.id === a)!, + y = run.cards.find((c) => c.id === b)!; + return a === b + ? 0 + : el.dataset.action === 'sort-suit' + ? x.suit - y.suit || y.rank - x.rank + : y.rank - x.rank || x.suit - y.suit; + }); + selected = ids.filter((id) => selected.includes(id)); + void command({ type: 'order', zone: 'hand', ids }); + return; + } + if (a === 'move-left' || a === 'move-right') { + const ids = run.jokers.map((j) => j.uid), + i = ids.indexOf(id), + to = i + (a === 'move-left' ? -1 : 1); + if (to >= 0 && to < ids.length) { + [ids[i], ids[to]] = [ids[to], ids[i]]; + void command({ type: 'order', zone: 'jokers', ids }); + } + return; + } + switch (a) { + case 'close': + closeModal(); + break; + case 'collection': + collection(); + break; + case 'settings': + settings(); + break; + case 'new': + newDialog(); + break; + case 'confirm-new': { + const seed = + (document.querySelector('#seed')?.value || makeSeed()).trim() || + makeSeed(); + run = createRun(seed); + selected = []; + persist(); + closeModal(); + render(); + break; + } + case 'sound': + sound.toggle(); + if (modalOpen) settings(); + render(); + break; + case 'speed': + speed = speed === 1 ? 2 : speed === 2 ? 4 : 1; + writeSetting('velvet-speed', String(speed)); + settings(); + break; + case 'crt': + crt = !crt; + writeSetting('velvet-crt', crt ? 'on' : 'off'); + game.classList.toggle('no-crt', !crt); + settings(); + break; + case 'runinfo': + openModal( + '这一局的故事', + `
最高单手${fmt(run.best)}累计得分${fmt(run.totalScore)}跳过盲注${run.skips}

种子 ${esc(run.seed)} · 牌组 ${run.cards.length} 张 · ${run.freeReroll ? '免费刷新待用' : '无免费刷新'}${run.investment ? ' · Boss 投资奖励待领取' : ''}

${run.history.length ? run.history.map((s) => `

${esc(s)}

`).join('') : '故事才刚开始。'}
`, + true, + ); + break; + case 'help': + openModal( + '让每张牌,都有戏。', + `
  1. 凑出牌型,越过盲注

    每次打出 1–5 张牌。筹码 × 倍率得到本次分数,累计达到目标即可过关。出牌次数用完则失败。

  2. 弃掉平庸,留下可能

    点击选牌,拖动调整顺序。弃牌会补充手牌,但次数有限;同一轮已打出和弃掉的牌不会重新抽到。

  3. 小丑让规则为你服务

    过关后进入商店。先加倍率,再乘倍率通常更有利。拖动上方小丑即可调整顺序,点击可查看或出售。

  4. 用星辰与塔罗改变命运

    行星升级牌型;塔罗改造已选手牌。点击右上消耗品使用。留钱可在过关时赚取利息。

  5. 走过八幕,击败最终 Boss

    每个底注有小盲、大盲和 Boss。普通盲注可跳过获取标签,Boss 不可跳过。

${btn('入席吧 →', 'close', 'gold-button')}
`, + true, + ); + break; + case 'about': + openModal( + '天鹅绒小丑', + `

A little luck.
A little madness.

一款独立卡面与内容的扑克构筑游戏。受 Balatro 启发,采用本作明确的规则与卡池。

24 位小丑 · 12 类牌型 · 8 幕挑战
原创程序绘制插画 · 全本地存档

`, + ); + break; + } +}); +document.addEventListener('keydown', (e) => { + if (modalOpen) { + if (e.key === 'Escape') { + closeModal(); + return; + } + if (e.key === 'Tab') { + const nodes = [...modal.querySelectorAll('button:not(:disabled),input')]; + if (!nodes.length) return; + const first = nodes[0], + last = nodes[nodes.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + return; + } + if (busy || run.phase !== 'play' || e.repeat) return; + if ((e.target as HTMLElement).tagName === 'BUTTON' && e.key === 'Enter') return; + if (/^[1-9]$/.test(e.key)) { + const id = run.hand[Number(e.key) - 1]; + if (id) select(id); + } + if (e.key === 'Enter' && selected.length) { + e.preventDefault(); + void command({ type: 'play', ids: [...selected] }); + } + if (e.key.toLowerCase() === 'd' && selected.length) + void command({ type: 'discard', ids: [...selected] }); + if (e.key === 'Escape') { + selected = []; + render(); + } +}); +try { + await board.init(document.querySelector('#canvas')!, { + select, + inspect, + hover(title, body, x, y) { + if (modalOpen) return; + tooltip.innerHTML = `${esc(title)}

${esc(body)}

`; + tooltip.style.left = Math.min(1110, Math.max(330, x - 125)) + 'px'; + tooltip.style.top = y + 'px'; + tooltip.hidden = false; + }, + unhover() { + tooltip.hidden = true; + }, + reorder(zone, from, to) { + const ids = zone === 'hand' ? [...run.hand] : run.jokers.map((j) => j.uid), + i = ids.indexOf(from), + j = ids.indexOf(to); + if (i < 0 || j < 0) return; + ids.splice(i, 1); + ids.splice(j, 0, from); + if (zone === 'hand') selected = ids.filter((id) => selected.includes(id)); + void command({ type: 'order', zone, ids }); + }, + }); + render(); + if (loaded.error) toast(loaded.error); + else if (loaded.run) toast('牌桌还在,欢迎回来。'); + else persist(); +} catch (error) { + console.error('Game renderer could not start', error); + openModal( + '牌桌未能启动', + `

浏览器未提供可用的 WebGL 渲染环境。请开启浏览器图形加速,或使用支持 WebGL 的新版 Chrome、Edge 或 Safari 后重试。

已有存档不会被修改。

`, + ); +} diff --git a/src/app/style.css b/src/app/style.css new file mode 100644 index 0000000..8e8e813 --- /dev/null +++ b/src/app/style.css @@ -0,0 +1,1206 @@ +:root { + font-family: 'Songti SC', 'STSong', 'Noto Serif CJK SC', serif; + color: #e9e0c7; + background: #0b1c18; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + overflow: hidden; + width: 100vw; + height: 100dvh; + display: flex; + align-items: center; + justify-content: center; +} +button, +input { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; + opacity: 0.38; +} +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 2px solid #f0d282; + outline-offset: 5px; +} +button { + color: inherit; +} +#fit { + position: relative; + flex-shrink: 0; +} +#game { + width: 1440px; + height: 900px; + position: absolute; + transform-origin: top left; + overflow: hidden; + background: #194438; +} +.felt { + position: absolute; + inset: 0; + background: + radial-gradient(ellipse at 65% 42%, #38615060, transparent 55%), + repeating-radial-gradient( + ellipse at 85% -80%, + transparent 0 90px, + #adc09603 91px 93px, + transparent 94px 110px + ), + linear-gradient(115deg, #143c30, #1e4b3e 55%, #163b31); +} +.table-grain { + position: absolute; + inset: 0; + opacity: 0.18; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' opacity='.6' filter='url(%23n)'/%3E%3C/svg%3E"); + mix-blend-mode: soft-light; + pointer-events: none; +} +#canvas { + position: absolute; + inset: 0; +} +#canvas canvas { + width: 1440px; + height: 900px; + touch-action: none; +} +#ui { + position: absolute; + inset: 0; + pointer-events: none; +} +#ui button, +#ui a, +#ui input { + pointer-events: auto; +} +header { + height: 92px; + position: absolute; + inset: 0 30px auto; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #c2ba8533; +} +.brand { + display: flex; + gap: 13px; + color: #eee5cb; + text-decoration: none; + align-items: center; + font-size: 25px; + letter-spacing: 2px; + font-weight: bold; +} +.brand i { + font-weight: normal; +} +.brand small { + display: block; + font-size: 9px; + letter-spacing: 5px; + margin-top: 6px; + color: #bba774; +} +.brand-mark { + font-size: 43px; + color: #d6bd80; + transform: rotate(-9deg); +} +.header-center { + font: + 10px 'Courier New', + monospace; + letter-spacing: 2.2px; + color: #adb99f; + margin-left: 80px; +} +.tiny-star { + color: #c8af77; + margin: 0 14px; +} +nav { + display: flex; + gap: 24px; +} +nav button { + border: 0; + background: none; + font-size: 12px; + color: #cbcbb6; + padding: 10px 0; +} +nav span { + margin-left: 8px; + color: #aaab8c; +} +.sidebar { + position: absolute; + left: 30px; + top: 112px; + width: 272px; + bottom: 67px; + padding: 15px; + background: linear-gradient(130deg, #152d27, #132a24); + border: 1px solid #b7ac7238; + border-radius: 12px; + box-shadow: + 3px 8px 0 #061b1638, + inset 0 1px #d3c3990b; +} +.ante-label { + display: flex; + justify-content: space-between; + font: + 8px 'Courier New', + monospace; + letter-spacing: 1px; + color: #9aab96; + margin: 0 0 17px; +} +.ante-label span:last-child { + font-family: serif; + letter-spacing: 0; +} +.blind-panel { + background: #213d35; + border: 1px solid #b5ae7b29; + border-radius: 7px; + overflow: hidden; +} +.blind-head { + height: 39px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + background: #2a5145; + font-size: 14px; + letter-spacing: 1px; +} +.round-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #d6bc7a; +} +.mini { + font: 8px 'Courier New'; + margin-left: auto; + color: #acc1a4; +} +.blind-target { + display: flex; + gap: 14px; + padding: 21px 13px 16px; + align-items: center; +} +.blind-coin { + display: flex; + width: 63px; + height: 63px; + align-items: center; + justify-content: center; + font-size: 36px; + color: #d5c49a; + border: 5px double #879e86; + border-radius: 50%; + background: #406854; + box-shadow: 0 4px 0 #102e23; + flex-shrink: 0; +} +.blind-target label { + font-size: 11px; + color: #b0bca7; + display: block; +} +.blind-target strong { + font: bold 31px Georgia; + color: #efdfb7; + display: block; + margin: 4px 0; +} +.blind-target small { + font-size: 10px; + color: #abb9a4; +} +.blind-target b { + color: #d9be7c; + letter-spacing: 3px; + margin-left: 5px; +} +.blind-note { + font-size: 10px; + line-height: 1.65; + color: #afbaa4; + margin: 0 12px; + padding: 10px 0 13px; + border-top: 1px solid #c4bc8a1c; + min-height: 40px; +} +.score-panel { + padding: 20px 3px 15px; + border-bottom: 1px solid #c4bc8a26; +} +.score-panel label { + font-size: 12px; + color: #aab7a3; + display: flex; + justify-content: space-between; +} +.score-panel label span { + font: 8px 'Courier New'; + letter-spacing: 1px; + color: #7a9585; +} +.score-panel > strong { + font: bold 39px Georgia; + display: block; + margin: 10px 0 13px; +} +.progress-track { + height: 4px; + border-radius: 4px; + background: #081f19; +} +.progress-track i { + display: block; + height: 100%; + border-radius: 4px; + background: #d5bb79; + transition: width 0.4s; +} +.score-goal { + font: 9px 'Courier New'; + display: flex; + justify-content: space-between; + color: #9baf9c; + margin-top: 9px; +} +.hand-panel { + padding: 18px 0 12px; +} +.hand-title { + display: flex; + justify-content: center; + align-items: baseline; + gap: 10px; + font-size: 21px; + font-weight: bold; + margin-bottom: 13px; +} +.hand-title small { + font: 9px 'Courier New'; + color: #99ac9a; +} +.equation { + display: flex; + align-items: center; + gap: 7px; +} +.equation > div { + width: 107px; + border-radius: 5px; + padding: 8px 9px; + box-shadow: 0 3px 0 #0004; +} +.equation > span { + font-size: 25px; + color: #cdb986; +} +.equation label { + font-size: 9px; + opacity: 0.75; + display: block; +} +.equation strong { + font: bold 31px Georgia; + display: block; + text-align: right; + line-height: 1.05; +} +.chips { + background: #387b98; + color: #e6f2e8; + border: 1px solid #8cb4bd4d; +} +.mult { + background: #b85d4d; + color: #ffe8d6; + border: 1px solid #e3a3885e; +} +.hand-panel p { + font-size: 9px; + color: #809b89; + text-align: center; + margin: 11px 0 0; +} +.counters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.counters > div { + background: #0e241e80; + border: 1px solid #91a07625; + border-radius: 5px; + padding: 9px 13px; + display: flex; + align-items: center; + justify-content: space-between; +} +.counters label { + font-size: 11px; + color: #9fb09d; +} +.counters strong { + font: bold 29px Georgia; +} +.blue { + color: #77b4cb; +} +.red { + color: #db8b76; +} +.gold { + color: #d9bf7c; +} +.wallet { + margin-top: 11px; + background: #0d251f80; + border: 1px solid #aaa17138; + border-radius: 6px; + padding: 11px 14px; + display: flex; + align-items: center; + justify-content: space-between; +} +.wallet > span { + font-size: 11px; + color: #b9b9a1; +} +.wallet small { + display: block; + font: 8px 'Courier New'; + letter-spacing: 2px; + color: #7d9986; + margin-top: 5px; +} +.wallet strong { + font: bold 36px Georgia; + color: #e1c689; +} +.rounds { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 15px; +} +.rounds > div { + text-align: center; +} +.rounds > div + div { + border-left: 1px solid #aeb48c26; +} +.rounds label { + font-size: 9px; + color: #90a790; + display: block; +} +.rounds strong { + display: block; + font: bold 29px Georgia; + margin-top: 5px; +} +.rounds small { + font: 15px Georgia; + color: #849a84; +} +.run-info { + border: 0; + border-top: 1px solid #c4bc8a26; + background: none; + width: 100%; + display: flex; + justify-content: space-between; + padding: 13px 3px 0; + margin-top: 15px; + font-size: 11px; + color: #b4c0a6; +} +.inventory-help { + position: absolute; + left: 370px; + top: 320px; + font-size: 9px; + letter-spacing: 1px; + color: #91aa94; +} +.table-content { + position: absolute; + left: 345px; + top: 349px; + width: 1045px; + height: 459px; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: end; + margin: 0 24px 21px; +} +.eyebrow { + font: 9px 'Courier New'; + letter-spacing: 2.8px; + color: #c1b180; +} +.section-heading h2 { + font-size: 29px; + letter-spacing: 2px; + font-weight: normal; + margin: 8px 0 0; +} +.section-heading > span { + font-size: 10px; + color: #a7b69c; + margin-bottom: 4px; +} +.blind-options { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 17px; + margin: 0 24px; +} +.blind-option { + height: 340px; + border: 1px solid #b2b88b35; + border-radius: 8px; + text-align: center; + padding: 15px 18px; + background: #102f264a; + position: relative; +} +.blind-option.active { + border-color: #c9ae73; + background: linear-gradient(150deg, #49725955, #1e423655); + box-shadow: 0 4px 0 #081f1a55; +} +.blind-option.completed { + opacity: 0.35; +} +.option-meta { + display: flex; + justify-content: space-between; + font: 8px 'Courier New'; + letter-spacing: 1.5px; + color: #aaba9f; +} +.option-meta span { + font-size: 8px; + letter-spacing: 0; +} +.token { + margin: 18px auto 12px; + border-radius: 50%; + width: 66px; + height: 66px; + font-size: 38px; + display: grid; + place-items: center; + border: 5px double #b5c8a1; + background: #47775b; + color: #d1deb7; + box-shadow: 0 4px 0 #0b281d; +} +.token-1 { + background: #435d74; + border-color: #99b2b9; + color: #b9d3d5; +} +.token-2 { + background: #765254; + border-color: #bc9490; + color: #e3c3ae; +} +.blind-option h3 { + font-size: 17px; + letter-spacing: 2px; + margin: 0 0 6px; + font-weight: normal; +} +.target-text { + font: bold 31px Georgia; + margin: 0; + color: #e3d8b7; +} +.target-text small { + font: 10px serif; + color: #aebb9f; + margin-left: 8px; +} +.option-desc { + font-size: 10px; + color: #a8b49b; + min-height: 35px; + line-height: 1.65; + margin: 10px 0; +} +.button { + border: 1px solid #e2d2a337; + border-radius: 5px; + min-height: 40px; + padding: 10px 20px; + background: #385848; + color: #e7dfc6; + box-shadow: 0 3px 0 #091d17b3; + font-weight: bold; + font-size: 13px; + transition: + filter 0.15s, + transform 0.15s; + white-space: nowrap; +} +.button:hover:not(:disabled) { + filter: brightness(1.14); + transform: translateY(-1px); +} +.button:active:not(:disabled) { + transform: translateY(2px); + box-shadow: none; +} +.gold-button { + background: #d1b779; + color: #263c30; + border-color: #edd59b; + box-shadow: 0 3px 0 #877349; +} +.blind-option .button { + width: 100%; +} +.skip { + margin-top: 10px; + border: 0; + background: none; + color: #b7bb9f; + font-size: 9px; + text-decoration: underline; + text-underline-offset: 4px; + padding: 3px; +} +.locked-label { + font-size: 10px; + color: #829881; + padding-top: 11px; +} +.table-idle { + position: absolute; + top: 7px; + left: 0; + width: 100%; + text-align: center; + pointer-events: none; +} +.table-seal { + font: bold italic 84px Georgia; + color: #bac6990a; + position: absolute; + left: 43%; + top: -13px; + width: 150px; + height: 150px; + border: 2px solid #d4d3a308; + border-radius: 50%; + transform: rotate(-13deg); +} +.table-seal span { + font-size: 61px; + margin-left: -25px; +} +.table-idle .eyebrow { + display: block; + margin-top: 56px; + font-size: 8px; + color: #b7b78a; +} +.table-idle h2 { + font-size: 25px; + font-weight: normal; + letter-spacing: 3px; + margin: 13px 0; + color: #e3dbbc; +} +.table-idle h2 b { + font: 30px Georgia; + color: #d9c38b; + letter-spacing: 1px; +} +.table-idle p { + font-size: 10px; + letter-spacing: 1px; + color: #a7b99e; +} +.hand-actions { + position: absolute; + top: 808px; + left: 554px; + display: flex; + gap: 19px; + align-items: center; +} +.hand-actions > .button { + width: 188px; + height: 52px; + font-size: 17px; + letter-spacing: 3px; + display: flex; + align-items: center; + justify-content: space-between; +} +.button span { + font: 10px 'Courier New'; + opacity: 0.65; + border: 1px solid #ffffff30; + padding: 3px 5px; + border-radius: 3px; +} +.blue-button { + background: #397f9b; + border-color: #8cafb674; +} +.red-button { + background: #af5b4c; + border-color: #da917772; +} +.sort-control { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + text-align: center; +} +.sort-control > span { + grid-column: span 2; + font-size: 9px; + color: #a7b79c; + margin-bottom: 2px; +} +.sort-control button { + font-size: 11px; + background: #243f33; + border: 1px solid #9eaa7640; + border-radius: 4px; + padding: 7px 12px; +} +.scoring-caption { + position: absolute; + left: 0; + top: 160px; + width: 100%; + text-align: center; +} +.scoring-caption h2 { + font: + bold 24px Georgia, + serif; + letter-spacing: 2px; + margin: 11px 0; + color: #e4c883; +} +.scoring-caption p { + font-size: 13px; + color: #d1d5bb; + margin: 0; +} +.shop-labels { + position: absolute; + top: 260px; + left: 0; +} +.shop-labels > div { + position: absolute; + text-align: center; +} +.shop-labels strong { + font-size: 14px; + font-weight: normal; + display: block; + letter-spacing: 1px; +} +.shop-labels small { + font-size: 9px; + color: #a6b99d; + display: block; + margin: 7px 0 12px; +} +.shop-buy { + padding: 7px 20px; + min-height: 33px; + color: #ddc993; + background: #1d392e; + border-color: #a299624d; +} +.pack-label strong { + font-size: 12px !important; +} +.shop-bottom { + position: absolute; + bottom: 10px; + left: 26px; + right: 25px; + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid #d4c49829; + padding-top: 22px; +} +.shop-bottom p { + font-size: 10px; + color: #a2b397; +} +.quiet-button { + background: #203c31; + border-color: #9ca77752; + font-weight: normal; +} +.result-panel { + position: absolute; + left: 255px; + right: 255px; + top: 12px; + text-align: center; + padding: 23px 29px; + background: #102c24ed; + border: 1px solid #c7b57d6b; + border-radius: 9px; + box-shadow: 0 8px 30px #051c1955; + pointer-events: auto; + z-index: 2; +} +.result-panel h2 { + font-size: 33px; + letter-spacing: 3px; + font-weight: normal; + margin: 12px 0; +} +.result-panel > p { + font-size: 12px; + color: #b6c0a5; +} +.result-panel > p strong { + font: bold 27px Georgia; + margin-left: 5px; +} +.receipt { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px 23px; + margin: 19px 0 24px; +} +.receipt span { + display: flex; + justify-content: space-between; + font-size: 10px; + color: #a6b59d; +} +.receipt b { + font: 13px Georgia; + color: #e3d6b4; +} +.result-panel .button { + min-width: 200px; +} +.end-panel { + left: 150px; + right: 150px; + top: 4px; + padding: 30px; +} +.end-stats { + display: flex; + justify-content: space-around; + gap: 25px; + margin: 27px 0; +} +.end-stats span { + font-size: 11px; + color: #aeb79e; +} +.end-stats b { + display: block; + margin-top: 10px; + font: bold 28px Georgia; + color: #d7c18b; +} +footer { + position: absolute; + bottom: 16px; + left: 32px; + right: 31px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 9px; + color: #829d88; + letter-spacing: 0.5px; +} +footer b { + font: 9px 'Courier New'; + font-weight: normal; + margin-left: 15px; +} +footer em { + font-style: normal; + margin: 0 8px; + color: #a8b68d; +} +footer button { + border: 0; + background: none; + font-size: 10px; + color: #acba9c; +} +.live-dot { + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + background: #9bb586; + margin-right: 6px; +} +.vignette { + position: absolute; + inset: 0; + box-shadow: inset 0 0 115px #02130c44; + pointer-events: none; + z-index: 10; +} +.scanlines { + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + #00000005 0px, + #00000005 1px, + transparent 1px, + transparent 3px + ); + pointer-events: none; + z-index: 11; + opacity: 0.5; +} +.no-crt .scanlines, +.no-crt .vignette { + display: none; +} +#tooltip { + position: absolute; + z-index: 12; + width: 260px; + background: #e9dfc2; + color: #293d31; + padding: 14px 17px; + border: 3px double #8b805d; + border-radius: 5px; + box-shadow: 3px 6px 0 #04181066; + pointer-events: none; +} +#tooltip strong { + font-size: 16px; +} +#tooltip p { + font-size: 12px; + line-height: 1.7; + margin: 8px 0 0; +} +#toast { + position: absolute; + bottom: 88px; + left: 50%; + transform: translate(-50%, 15px); + z-index: 30; + opacity: 0; + pointer-events: none; + background: #e1cf9d; + color: #283c2e; + padding: 13px 23px; + border: 1px solid #f0dcaa; + border-radius: 5px; + font-size: 13px; + box-shadow: 0 4px 15px #071a1455; + transition: + opacity 0.2s, + transform 0.2s; +} +#toast.visible { + opacity: 1; + transform: translate(-50%, 0); +} +.modal-backdrop { + position: absolute; + inset: 0; + background: #071c17bb; + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + z-index: 20; +} +.dialog { + position: relative; + width: 530px; + max-height: 790px; + overflow: auto; + background: linear-gradient(125deg, #254536, #19372c); + border: 1px solid #c7ae76; + border-radius: 9px; + box-shadow: + 0 13px 0 #081e1677, + 0 30px 80px #00170e80; + padding: 35px 38px; + color: #e6dfc6; +} +.dialog.wide { + width: 750px; +} +.dialog h2 { + font-size: 29px; + letter-spacing: 2px; + font-weight: normal; + margin: 12px 0 22px; +} +.dialog p { + font-size: 13px; + line-height: 1.9; +} +.dialog-close { + position: absolute; + right: 18px; + top: 13px; + border: 0; + background: none; + color: #cabd94; + font-size: 27px; +} +.muted { + color: #aebba0; + font-size: 12px !important; +} +.detail-tag { + font: 10px 'Courier New'; + letter-spacing: 2px; + color: #d3ba81; +} +.detail-description { + font-size: 20px !important; + line-height: 1.8 !important; + margin: 23px 0; +} +.dialog-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 25px; +} +.dialog-actions .button { + font-size: 12px; +} +.setting { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 0; + border-bottom: 1px solid #a4b0872e; +} +.setting > span { + font-size: 15px; +} +.setting small { + display: block; + font-size: 10px; + color: #a7b49a; + margin-top: 7px; +} +.seed-label { + display: block; + font-size: 11px; + margin: 20px 0 8px; + color: #c9c39f; +} +input { + background: #122e23; + color: #dfd3ae; + border: 1px solid #a2a26e73; + border-radius: 4px; + padding: 13px; + font: 16px 'Courier New'; + width: 100%; + letter-spacing: 3px; +} +.hand-table { + border-collapse: collapse; + width: 100%; + font-size: 13px; + margin-top: 20px; +} +.hand-table th { + text-align: left; + font-size: 11px; + font-weight: normal; + color: #b3b794; + border-bottom: 1px solid #abac7e5c; + padding: 8px; +} +.hand-table td { + padding: 10px 8px; + border-bottom: 1px solid #8da47724; +} +.hand-table small { + opacity: 0.55; + font-size: 10px; +} +.guide { + padding-left: 20px; + counter-reset: item; +} +.guide li { + padding-left: 8px; + margin: 20px 0; + color: #d8c390; +} +.guide strong { + font-size: 16px; + color: #e3d9b8; +} +.guide p { + font-size: 12px; + color: #aebba3; + margin: 6px 0; +} +.history { + max-height: 280px; + overflow: auto; + background: #112e2280; + padding: 15px 20px; + font-size: 12px; +} +.history p { + border-bottom: 1px solid #829f7022; + margin: 0; + padding: 7px 0; +} +.rotate { + display: none; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +@media (orientation: portrait) and (max-width: 850px) { + .rotate { + display: flex; + position: fixed; + inset: 0; + background: #102c24f5; + z-index: 100; + align-items: center; + justify-content: center; + flex-direction: column; + font-size: 55px; + color: #d5bf88; + gap: 20px; + } + .rotate strong { + font-size: 21px; + letter-spacing: 2px; + font-weight: normal; + } + .rotate span { + font-size: 12px; + color: #b0bd9f; + } +} +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } +} + +/* Compact the sidebar so every control stays inside the table at the design size. */ +.blind-target { + padding: 14px 13px 10px; +} +.blind-target strong { + font-size: 29px; + margin: 3px 0; +} +.blind-note { + padding: 8px 0 10px; + font-size: 9px; + min-height: 34px; +} +.score-panel { + padding: 14px 3px 11px; +} +.score-panel > strong { + font-size: 35px; + margin: 8px 0 10px; +} +.hand-panel { + padding: 14px 0 9px; +} +.hand-title { + font-size: 20px; + margin-bottom: 10px; +} +.hand-panel p { + margin-top: 8px; +} +.equation > div { + padding: 7px 9px; +} +.equation strong { + font-size: 29px; +} +.counters > div { + padding: 7px 13px; +} +.counters strong { + font-size: 26px; +} +.wallet { + padding: 8px 14px; + margin-top: 10px; +} +.wallet strong { + font-size: 32px; +} +.rounds { + margin-top: 12px; +} +.rounds strong { + font-size: 25px; +} +.run-info { + margin-top: 12px; + padding-top: 11px; +} diff --git a/src/content/catalog.ts b/src/content/catalog.ts new file mode 100644 index 0000000..a73117d --- /dev/null +++ b/src/content/catalog.ts @@ -0,0 +1,288 @@ +export const HANDS = [ + ['高牌', 5, 1, 10, 1, '冥王星'], + ['对子', 10, 2, 15, 1, '水星'], + ['两对', 20, 2, 20, 1, '天王星'], + ['三条', 30, 3, 20, 2, '金星'], + ['顺子', 30, 4, 30, 3, '土星'], + ['同花', 35, 4, 15, 2, '木星'], + ['葫芦', 40, 4, 25, 2, '地球'], + ['四条', 60, 7, 30, 3, '火星'], + ['同花顺', 100, 8, 40, 4, '海王星'], + ['五条', 120, 12, 35, 3, 'X 行星'], + ['同花葫芦', 140, 14, 40, 4, '谷神星'], + ['同花五条', 160, 16, 50, 3, '阋神星'], +] as const; +export type JokerDef = { + id: string; + name: string; + en: string; + desc: string; + price: number; + rarity: string; + color: string; + motif: number; +}; +export const JOKERS: JokerDef[] = [ + { + id: 'smile', + name: '微笑先生', + en: 'THE SMILER', + desc: '每次出牌 +4 倍率。', + price: 4, + rarity: '普通', + color: '#d36856', + motif: 0, + }, + { + id: 'pair', + name: '双面演员', + en: 'TWO FACED', + desc: '打出的牌包含对子时,+8 倍率。', + price: 5, + rarity: '普通', + color: '#7296a0', + motif: 1, + }, + { + id: 'triple', + name: '三重奏', + en: 'TRIO', + desc: '包含三条时,+20 倍率。', + price: 6, + rarity: '普通', + color: '#b69253', + motif: 2, + }, + { + id: 'flush', + name: '花色诗人', + en: 'SUIT POET', + desc: '5 张同花色时,+18 倍率。', + price: 6, + rarity: '普通', + color: '#749574', + motif: 3, + }, + { + id: 'straight', + name: '走钢丝', + en: 'TIGHTROPE', + desc: '包含顺子时,+100 筹码。', + price: 5, + rarity: '普通', + color: '#bb795f', + motif: 4, + }, + { + id: 'chips', + name: '筹码匠', + en: 'CHIP MAKER', + desc: '每次出牌 +80 筹码。', + price: 5, + rarity: '普通', + color: '#5896aa', + motif: 5, + }, + { + id: 'hearts', + name: '红心皇后', + en: 'QUEEN OF HEARTS', + desc: '每张计分红桃 +4 倍率。', + price: 6, + rarity: '普通', + color: '#bc5869', + motif: 6, + }, + { + id: 'spades', + name: '夜行客', + en: 'NIGHT WALKER', + desc: '每张计分黑桃 +35 筹码。', + price: 5, + rarity: '普通', + color: '#6c779b', + motif: 7, + }, + { + id: 'diamonds', + name: '钻石眼', + en: 'DIAMOND EYE', + desc: '每张计分方块赚取 $1。', + price: 6, + rarity: '罕见', + color: '#c59b62', + motif: 8, + }, + { + id: 'clubs', + name: '三叶草', + en: 'CLOVER', + desc: '每张计分梅花 +4 倍率。', + price: 5, + rarity: '普通', + color: '#639176', + motif: 9, + }, + { + id: 'faces', + name: '宫廷画师', + en: 'COURT PAINTER', + desc: '每张计分 J、Q、K +5 倍率。', + price: 6, + rarity: '普通', + color: '#b08b5c', + motif: 10, + }, + { + id: 'aces', + name: '王牌魔术', + en: 'ACE MAGIC', + desc: '每张计分 A +30 筹码、+4 倍率。', + price: 6, + rarity: '罕见', + color: '#839aac', + motif: 11, + }, + { + id: 'even', + name: '偶数哲学', + en: 'EVEN ODDS', + desc: '每张计分 2、4、6、8、10 +4 倍率。', + price: 5, + rarity: '普通', + color: '#8c9c72', + motif: 12, + }, + { + id: 'odd', + name: '奇数俱乐部', + en: 'ODD SOCIETY', + desc: '每张计分 A、3、5、7、9 +35 筹码。', + price: 5, + rarity: '普通', + color: '#a67d98', + motif: 13, + }, + { + id: 'echo', + name: '回声', + en: 'ENCORE', + desc: '第一张计分牌额外触发一次。', + price: 8, + rarity: '罕见', + color: '#70a5a0', + motif: 14, + }, + { + id: 'moon', + name: '月后', + en: 'MOON QUEEN', + desc: '每张留在手中的 Q +13 倍率。', + price: 6, + rarity: '罕见', + color: '#8a83b0', + motif: 15, + }, + { + id: 'double', + name: '双倍赌注', + en: 'DOUBLE DOWN', + desc: '打出的牌包含对子时,倍率 ×2。', + price: 9, + rarity: '稀有', + color: '#bb6656', + motif: 16, + }, + { + id: 'minimal', + name: '独幕剧', + en: 'SOLO ACT', + desc: '打出不超过 3 张牌时,倍率 ×2。', + price: 8, + rarity: '罕见', + color: '#7797a0', + motif: 17, + }, + { + id: 'growth', + name: '常青藤', + en: 'EVERGREEN', + desc: '每次出牌永久积累 +2 倍率;弃牌减少 1,最低 0。', + price: 7, + rarity: '罕见', + color: '#73946b', + motif: 18, + }, + { + id: 'scholar', + name: '老学者', + en: 'THE SCHOLAR', + desc: '倍率增加本局该牌型已打出次数 ×3(含本次)。', + price: 7, + rarity: '罕见', + color: '#ad9570', + motif: 19, + }, + { + id: 'bank', + name: '银行家', + en: 'THE BANKER', + desc: '每持有 $5,+4 倍率(结算到此刻的余额)。', + price: 7, + rarity: '罕见', + color: '#8aa579', + motif: 20, + }, + { + id: 'egg', + name: '金蛋', + en: 'GOLDEN EGG', + desc: '每次过关额外赚取 $4。', + price: 5, + rarity: '普通', + color: '#ceac65', + motif: 21, + }, + { + id: 'last', + name: '最后一舞', + en: 'LAST DANCE', + desc: '本轮最后一次出牌,倍率 ×3。', + price: 7, + rarity: '罕见', + color: '#a77a93', + motif: 22, + }, + { + id: 'wanderer', + name: '流浪艺人', + en: 'THE WANDERER', + desc: '每跳过一个盲注,永久增加 ×0.35 倍率,初始 ×1。', + price: 8, + rarity: '稀有', + color: '#af9367', + motif: 23, + }, +]; +export const TAROTS = [ + { name: '恋人', desc: '选择 1–3 张牌,转为红桃。', max: 3 }, + { name: '月亮', desc: '选择 1–3 张牌,转为黑桃。', max: 3 }, + { name: '力量', desc: '选择 1–2 张牌,点数 +1(A 变为 2)。', max: 2 }, + { name: '魔术师', desc: '选择 1 张牌,复制到手牌与牌组。', max: 1 }, + { name: '倒吊人', desc: '选择 1–2 张牌,永久移除。', max: 2 }, + { name: '正义', desc: '选择 1 张牌,变成玻璃牌:计分 ×2 倍率,1/4 概率破碎。', max: 1 }, + { name: '战车', desc: '选择 1 张牌,变成钢铁牌:留手 ×1.5 倍率。', max: 1 }, + { name: '太阳', desc: '选择 1 张牌,增加 +30 筹码强化与红色蜡封(重触发)。', max: 1 }, + { name: '女皇', desc: '选择 1–2 张牌,变成多倍牌:计分时 +4 倍率。', max: 2 }, +]; +export const BOSSES = [ + { name: '红心禁令', en: 'THE HEART', desc: '所有红桃牌失效:仍可组成牌型,但不触发计分。' }, + { name: '黑夜帷幕', en: 'THE VEIL', desc: '所有黑桃牌失效:仍可组成牌型,但不触发计分。' }, + { name: '狭窄舞台', en: 'THE STAGE', desc: '本轮手牌上限减少 1。' }, + { name: '孤注一掷', en: 'THE GAMBLE', desc: '本轮没有弃牌次数。' }, + { name: '满员谢幕', en: 'THE CAST', desc: '每次必须打出 5 张牌。' }, + { name: '钝刃', en: 'THE FLINT', desc: '牌型基础筹码与倍率减半,向上取整。' }, + { name: '天鹅绒终幕', en: 'THE FINAL CURTAIN', desc: '最终挑战:目标分数为基础分数的 4 倍。' }, +]; +export const BASE_TARGETS = [300, 800, 2000, 5000, 11000, 20000, 35000, 50000]; +export const jokerDef = (id: string) => JOKERS.find((j) => j.id === id)!; diff --git a/src/core/poker.ts b/src/core/poker.ts new file mode 100644 index 0000000..aeb6b89 --- /dev/null +++ b/src/core/poker.ts @@ -0,0 +1,41 @@ +import { HANDS } from '../content/catalog'; +import type { Card } from './types'; +export function evaluate(cards: Card[]) { + if (!cards.length || cards.length > 5) throw Error('请选择 1–5 张牌'); + const groups = new Map(); + for (const c of cards) groups.set(c.rank, [...(groups.get(c.rank) || []), c]); + const sorted = [...groups.values()].sort((a, b) => b.length - a.length || b[0].rank - a[0].rank); + const ranks = [...groups.keys()].sort((a, b) => a - b); + const flush = cards.length === 5 && cards.every((c) => c.suit === cards[0].suit); + const straight = + ranks.length === 5 && (ranks[4] - ranks[0] === 4 || ranks.join(',') === '2,3,4,5,14'); + const max = sorted[0].length, + pairs = sorted.filter((g) => g.length >= 2).length; + let hand = 0, + scoring: Card[] = []; + if (max === 5 && flush) hand = 11; + else if (max === 3 && pairs === 2 && flush) hand = 10; + else if (max === 5) hand = 9; + else if (straight && flush) hand = 8; + else if (max === 4) hand = 7; + else if (max === 3 && pairs === 2) hand = 6; + else if (flush) hand = 5; + else if (straight) hand = 4; + else if (max === 3) hand = 3; + else if (pairs === 2) hand = 2; + else if (max === 2) hand = 1; + if ([4, 5, 6, 8, 9, 10, 11].includes(hand)) scoring = cards; + else if (hand === 2) scoring = sorted.slice(0, 2).flat(); + else if (hand === 0) scoring = [cards.reduce((a, b) => (a.rank >= b.rank ? a : b))]; + else scoring = sorted[0]; + const ids = new Set(scoring.map((c) => c.id)); + return { + hand, + name: hand === 8 && ranks[0] === 10 ? '皇家同花顺' : HANDS[hand][0], + scoring: cards.filter((c) => ids.has(c.id)), + pair: max >= 2, + triple: max >= 3, + flush, + straight, + }; +} diff --git a/src/core/random.ts b/src/core/random.ts new file mode 100644 index 0000000..ccf832c --- /dev/null +++ b/src/core/random.ts @@ -0,0 +1,22 @@ +import type { Run } from './types'; +export function hash(s: string) { + let n = 2166136261; + for (const c of s) n = Math.imul(n ^ c.charCodeAt(0), 16777619); + return n >>> 0 || 1; +} +export function random(run: Run, stream: keyof Run['rng']) { + let n = run.rng[stream]; + n ^= n << 13; + n ^= n >>> 17; + n ^= n << 5; + run.rng[stream] = n >>> 0; + return (n >>> 0) / 4294967296; +} +export function shuffle(run: Run, list: T[], stream: keyof Run['rng'] = 'deck') { + const a = [...list]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(random(run, stream) * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} diff --git a/src/core/run.ts b/src/core/run.ts new file mode 100644 index 0000000..76ebd6d --- /dev/null +++ b/src/core/run.ts @@ -0,0 +1,349 @@ +import { BASE_TARGETS, HANDS, JOKERS, TAROTS, jokerDef } from '../content/catalog'; +import { hash, random, shuffle } from './random'; +import { scoreHand } from './scoring'; +import type { Card, Command, Item, Run, Suit } from './types'; +export function createRun(seed: string): Run { + const r: Run = { + version: 1, + rules: 'velvet-v1', + seed, + phase: 'blind', + ante: 1, + blind: 0, + round: 0, + money: 4, + score: 0, + target: 300, + hands: 4, + discards: 3, + handSize: 8, + cards: [], + hand: [], + draw: [], + spent: [], + jokers: [], + consumables: [], + levels: HANDS.map(() => 1), + counts: HANDS.map(() => 0), + bosses: [], + rng: { deck: hash(seed + 'deck'), shop: hash(seed + 'shop'), effect: hash(seed + 'effect') }, + nextId: 100, + shop: [], + packs: [], + packChoices: [], + reroll: 5, + freeReroll: false, + investment: false, + skips: 0, + last: null, + reward: null, + best: 0, + totalScore: 0, + history: [], + }; + for (let suit = 0; suit < 4; suit++) + for (let rank = 2; rank <= 14; rank++) + r.cards.push({ id: r.cards.length + 1, rank, suit: suit as Suit }); + r.bosses = [...shuffle(r, [0, 1, 2, 3, 4, 5], 'effect'), Math.floor(random(r, 'effect') * 6), 6]; + return r; +} +export function target(r: Run, blind = r.blind) { + return BASE_TARGETS[r.ante - 1] * (blind === 0 ? 1 : blind === 1 ? 1.5 : r.ante === 8 ? 4 : 2); +} +const fill = (r: Run) => { + while (r.hand.length < r.handSize && r.draw.length) r.hand.push(r.draw.pop()!); +}; +function item(r: Run, kind: Item['kind'], ref: number, price: number): Item { + return { uid: r.nextId++, kind, ref, price }; +} +function rollItem(r: Run, kind?: 'joker' | 'planet' | 'tarot', excluded: string[] = []): Item { + const q = random(r, 'shop'); + kind ??= q < 0.58 ? 'joker' : q < 0.81 ? 'planet' : 'tarot'; + if (kind === 'joker') { + const pool = JOKERS.filter( + (j) => !r.jokers.some((x) => x.id === j.id) && !excluded.includes(j.id), + ); + if (pool.length) { + const j = pool[Math.floor(random(r, 'shop') * pool.length)]; + return item(r, 'joker', JOKERS.indexOf(j), j.price); + } + kind = 'planet'; + } + if (kind === 'planet') { + const pool = HANDS.map((_, i) => i).filter((i) => i < 9 || r.counts[i] > 0); + return item(r, 'planet', pool[Math.floor(random(r, 'shop') * pool.length)], 3); + } + return item(r, 'tarot', Math.floor(random(r, 'shop') * TAROTS.length), 3); +} +function stock(r: Run) { + r.shop = []; + for (let i = 0; i < 3; i++) + r.shop.push( + rollItem( + r, + i === 0 ? 'joker' : undefined, + r.shop.filter((s) => s.kind === 'joker').map((s) => JOKERS[s.ref].id), + ), + ); +} +const requirePhase = (r: Run, ...phases: Run['phase'][]) => { + if (!phases.includes(r.phase)) throw Error('当前阶段无法进行此操作'); +}; +function picked(r: Run, ids: number[]) { + if ( + !ids.length || + ids.length > 5 || + new Set(ids).size !== ids.length || + ids.some((id) => !r.hand.includes(id)) + ) + throw Error('请选择 1–5 张手牌'); + return ids.map((id) => r.cards.find((c) => c.id === id)!); +} +function advance(r: Run) { + if (r.blind === 2) { + r.blind = 0; + r.ante++; + } else r.blind++; + r.phase = 'blind'; + r.target = target(r); + r.score = 0; + r.hands = 4; + r.discards = r.blind === 2 && r.bosses[r.ante - 1] === 3 ? 0 : 3; + r.reward = null; + r.last = null; + r.hand = []; + r.draw = []; + r.spent = []; +} +function give(r: Run, i: Item) { + if (i.kind === 'joker') { + if (r.jokers.length >= 5) throw Error('小丑槽已满,请先出售一张'); + r.jokers.push({ uid: i.uid, id: JOKERS[i.ref].id, value: 0 }); + } else { + if (r.consumables.length >= 2) throw Error('消耗品槽已满,请先使用或出售'); + r.consumables.push(i); + } +} +function apply(r: Run, c: Command) { + switch (c.type) { + case 'enter': { + requirePhase(r, 'blind'); + r.phase = 'play'; + r.round++; + r.score = 0; + r.hands = 4; + r.discards = r.blind === 2 && r.bosses[r.ante - 1] === 3 ? 0 : 3; + r.handSize = r.blind === 2 && r.bosses[r.ante - 1] === 2 ? 7 : 8; + r.target = target(r); + r.hand = []; + r.spent = []; + r.draw = shuffle( + r, + r.cards.map((c) => c.id), + ); + r.last = null; + r.reward = null; + fill(r); + if (!r.hand.length) r.phase = 'lost'; + break; + } + case 'skip': + requirePhase(r, 'blind'); + if (r.blind === 2) throw Error('Boss 盲注不可跳过'); + r.skips++; + if (r.blind === 0) r.freeReroll = true; + else r.investment = true; + advance(r); + break; + case 'play': { + requirePhase(r, 'play'); + const cards = picked(r, c.ids); + if (r.hands <= 0) throw Error('没有出牌次数'); + if (r.blind === 2 && r.bosses[r.ante - 1] === 4 && cards.length !== 5) + throw Error('这个 Boss 要求每次打出 5 张牌'); + const result = scoreHand(r, cards); + r.last = result; + r.hands--; + r.score += result.total; + r.totalScore += result.total; + r.best = Math.max(r.best, result.total); + r.hand = r.hand.filter((id) => !c.ids.includes(id)); + r.spent.push(...c.ids.filter((id) => r.cards.some((x) => x.id === id))); + r.history.unshift(`${result.name} · ${result.total.toLocaleString()} 分`); + r.history = r.history.slice(0, 20); + if (r.score >= r.target) { + const base = 3 + r.blind, + interest = Math.min(5, Math.floor(r.money / 5)), + bonus = + r.jokers.filter((j) => j.id === 'egg').length * 4 + + (r.blind === 2 && r.investment ? 15 : 0); + r.reward = { + base, + hands: r.hands, + interest, + bonus, + total: base + r.hands + interest + bonus, + }; + r.money += r.reward.total; + if (r.blind === 2) r.investment = false; + r.phase = r.ante === 8 && r.blind === 2 ? 'won' : 'reward'; + } else if (r.hands === 0 || r.hand.length + r.draw.length === 0) r.phase = 'lost'; + else fill(r); + break; + } + case 'discard': { + requirePhase(r, 'play'); + picked(r, c.ids); + if (r.discards <= 0) throw Error('没有弃牌次数'); + r.discards--; + r.hand = r.hand.filter((id) => !c.ids.includes(id)); + r.spent.push(...c.ids); + for (const j of r.jokers) if (j.id === 'growth') j.value = Math.max(0, j.value - 1); + fill(r); + if (!r.hand.length) r.phase = 'lost'; + break; + } + case 'cashout': + requirePhase(r, 'reward'); + r.phase = 'shop'; + r.reroll = 5; + stock(r); + r.packs = [item(r, 'pack', 0, 4), item(r, 'pack', 1, 4)]; + break; + case 'next': + requirePhase(r, 'shop'); + advance(r); + break; + case 'reroll': + requirePhase(r, 'shop'); + if (!r.freeReroll) { + if (r.money < r.reroll) throw Error('余额不足'); + r.money -= r.reroll; + r.reroll++; + } else r.freeReroll = false; + stock(r); + break; + case 'buy': { + requirePhase(r, 'shop'); + const i = [...r.shop, ...r.packs].find((i) => i.uid === c.uid); + if (!i) throw Error('商品已售出'); + if (r.money < i.price) throw Error('余额不足'); + if (i.kind === 'pack') { + r.packChoices = []; + for (let n = 0; n < 3; n++) + r.packChoices.push( + rollItem( + r, + i.ref === 0 ? 'joker' : random(r, 'shop') < 0.5 ? 'planet' : 'tarot', + r.packChoices.filter((x) => x.kind === 'joker').map((x) => JOKERS[x.ref].id), + ), + ); + r.phase = 'pack'; + r.packs = r.packs.filter((x) => x.uid !== i.uid); + } else { + give(r, i); + r.shop = r.shop.filter((x) => x.uid !== i.uid); + } + r.money -= i.price; + break; + } + case 'take': { + requirePhase(r, 'pack'); + const i = r.packChoices.find((i) => i.uid === c.uid); + if (!i) throw Error('无效的卡包选项'); + give(r, i); + r.packChoices = []; + r.phase = 'shop'; + break; + } + case 'closePack': + requirePhase(r, 'pack'); + r.packChoices = []; + r.phase = 'shop'; + break; + case 'sell': { + requirePhase(r, 'shop', 'play', 'blind'); + const j = r.jokers.find((j) => j.uid === c.uid), + i = r.consumables.find((i) => i.uid === c.uid); + if (!j && !i) throw Error('卡牌不存在'); + r.money += Math.max(1, Math.floor((j ? jokerDef(j.id).price : i!.price) / 2)); + r.jokers = r.jokers.filter((j) => j.uid !== c.uid); + r.consumables = r.consumables.filter((i) => i.uid !== c.uid); + break; + } + case 'use': { + requirePhase(r, 'play'); + const i = r.consumables.find((i) => i.uid === c.uid); + if (!i) throw Error('消耗品不存在'); + if (i.kind === 'planet') r.levels[i.ref]++; + else { + const cs = picked(r, c.ids); + if (cs.length > TAROTS[i.ref].max) throw Error(`最多选择 ${TAROTS[i.ref].max} 张牌`); + if (i.ref === 4 && r.cards.length <= cs.length) throw Error('牌组至少保留 1 张牌'); + for (const card of cs) { + if (i.ref === 0) card.suit = 0; + if (i.ref === 1) card.suit = 1; + if (i.ref === 2) card.rank = card.rank === 14 ? 2 : card.rank + 1; + if (i.ref === 3) { + const copy = { ...card, id: r.nextId++ }; + r.cards.push(copy); + r.hand.push(copy.id); + } + if (i.ref === 4) { + r.cards = r.cards.filter((x) => x.id !== card.id); + r.hand = r.hand.filter((id) => id !== card.id); + } + if (i.ref === 5) card.enh = 'glass'; + if (i.ref === 6) card.enh = 'steel'; + if (i.ref === 7) { + card.enh = 'bonus'; + card.seal = true; + } + if (i.ref === 8) card.enh = 'mult'; + } + } + r.consumables = r.consumables.filter((x) => x.uid !== c.uid); + break; + } + case 'order': { + requirePhase(r, 'play', 'shop', 'blind'); + const old = c.zone === 'hand' ? r.hand : r.jokers.map((j) => j.uid); + if ( + old.length !== c.ids.length || + new Set(c.ids).size !== old.length || + c.ids.some((id) => !old.includes(id)) + ) + throw Error('无效的排序'); + if (c.zone === 'hand') r.hand = c.ids; + else r.jokers = c.ids.map((id) => r.jokers.find((j) => j.uid === id)!); + break; + } + } +} +export function dispatch(state: Run, command: Command): { state: Run; error?: string } { + const next = structuredClone(state); + try { + apply(next, command); + // A depleted or heavily trimmed deck must never leave an unwinnable input screen. + if (next.phase === 'play') { + const required = next.blind === 2 && next.bosses[next.ante - 1] === 4 ? 5 : 1; + const canDraw = + next.discards > 0 && + next.hand.length > 0 && + next.draw.length > 0 && + Math.min(next.handSize, next.hand.length - 1 + next.draw.length) >= required; + const copies = next.consumables.filter((i) => i.kind === 'tarot' && i.ref === 3).length; + const canCopy = next.hand.length > 0 && next.hand.length + copies >= required; + if (next.hand.length < required && !canDraw && !canCopy) next.phase = 'lost'; + } + return { state: next }; + } catch (e) { + return { state, error: e instanceof Error ? e.message : '操作失败' }; + } +} +export function cardName(c: Card) { + return ( + ['红桃', '黑桃', '方块', '梅花'][c.suit] + + ({ 11: 'J', 12: 'Q', 13: 'K', 14: 'A' }[c.rank] || c.rank) + ); +} diff --git a/src/core/scoring.ts b/src/core/scoring.ts new file mode 100644 index 0000000..b4a78f3 --- /dev/null +++ b/src/core/scoring.ts @@ -0,0 +1,164 @@ +import { HANDS, jokerDef } from '../content/catalog'; +import { evaluate } from './poker'; +import { random } from './random'; +import type { Card, Run, Score, Trace } from './types'; +export const debuffed = (run: Run, c: Card) => + run.blind === 2 && + ((run.bosses[run.ante - 1] === 0 && c.suit === 0) || + (run.bosses[run.ante - 1] === 1 && c.suit === 1)); +export function scoreHand(run: Run, played: Card[]): Score { + const e = evaluate(played), + h = HANDS[e.hand], + lv = run.levels[e.hand] - 1; + let chips: number = h[1] + h[3] * lv, + mult: number = h[2] + h[4] * lv; + if (run.blind === 2 && run.bosses[run.ante - 1] === 5) { + chips = Math.ceil(chips / 2); + mult = Math.ceil(mult / 2); + } + const trace: Trace[] = [{ source: e.name, label: `${e.name} · Lv.${lv + 1}`, chips, mult }]; + const add = ( + source: string, + label: string, + c = 0, + m = 0, + x = 1, + cardId?: number, + jokerUid?: number, + ) => { + chips += c; + mult = (mult + m) * x; + trace.push({ source, label, chips, mult, cardId, jokerUid }); + }; + run.counts[e.hand]++; + for (const j of run.jokers) if (j.id === 'growth') j.value += 2; + e.scoring.forEach((c, index) => { + if (debuffed(run, c)) { + trace.push({ source: '失效', label: 'Boss 封印', chips, mult, cardId: c.id }); + return; + } + const times = + 1 + (c.seal ? 1 : 0) + (index === 0 ? run.jokers.filter((j) => j.id === 'echo').length : 0); + for (let t = 0; t < times; t++) { + add( + t ? '再次触发' : '扑克牌', + `+${c.rank === 14 ? 11 : Math.min(c.rank, 10)} 筹码`, + c.rank === 14 ? 11 : Math.min(c.rank, 10), + 0, + 1, + c.id, + ); + if (c.enh === 'bonus') add('奖励牌', '+30 筹码', 30, 0, 1, c.id); + if (c.enh === 'mult') add('多倍牌', '+4 倍率', 0, 4, 1, c.id); + if (c.enh === 'glass') add('玻璃牌', '×2 倍率', 0, 0, 2, c.id); + for (const j of run.jokers) { + let cb = 0, + mb = 0; + if (j.id === 'hearts' && c.suit === 0) mb = 4; + if (j.id === 'spades' && c.suit === 1) cb = 35; + if (j.id === 'clubs' && c.suit === 3) mb = 4; + if (j.id === 'diamonds' && c.suit === 2) { + run.money++; + add(jokerDef(j.id).name, '+$1', 0, 0, 1, c.id, j.uid); + } + if (j.id === 'faces' && c.rank >= 11 && c.rank <= 13) mb = 5; + if (j.id === 'aces' && c.rank === 14) { + cb = 30; + mb = 4; + } + if (j.id === 'even' && [2, 4, 6, 8, 10].includes(c.rank)) mb = 4; + if (j.id === 'odd' && [3, 5, 7, 9, 14].includes(c.rank)) cb = 35; + if (cb || mb) + add( + jokerDef(j.id).name, + [cb ? `+${cb} 筹码` : '', mb ? `+${mb} 倍率` : ''].filter(Boolean).join(' · '), + cb, + mb, + 1, + c.id, + j.uid, + ); + } + } + }); + const selected = new Set(played.map((c) => c.id)); + for (const id of run.hand) { + const c = run.cards.find((c) => c.id === id)!; + if (selected.has(id) || debuffed(run, c)) continue; + for (let t = 0; t < (c.seal ? 2 : 1); t++) { + if (c.enh === 'steel') add('钢铁牌', '×1.5 倍率', 0, 0, 1.5, c.id); + for (const j of run.jokers) + if (j.id === 'moon' && c.rank === 12) add('月后', '+13 倍率', 0, 13, 1, c.id, j.uid); + } + } + for (const j of run.jokers) { + let cb = 0, + mb = 0, + x = 1; + switch (j.id) { + case 'smile': + mb = 4; + break; + case 'pair': + if (e.pair) mb = 8; + break; + case 'triple': + if (e.triple) mb = 20; + break; + case 'flush': + if (e.flush) mb = 18; + break; + case 'straight': + if (e.straight) cb = 100; + break; + case 'chips': + cb = 80; + break; + case 'double': + if (e.pair) x = 2; + break; + case 'minimal': + if (played.length <= 3) x = 2; + break; + case 'growth': + mb = j.value; + break; + case 'scholar': + mb = run.counts[e.hand] * 3; + break; + case 'bank': + mb = Math.floor(run.money / 5) * 4; + break; + case 'last': + if (run.hands === 1) x = 3; + break; + case 'wanderer': + x = 1 + run.skips * 0.35; + break; + } + if (cb || mb || x !== 1) + add( + jokerDef(j.id).name, + cb ? `+${cb} 筹码` : mb ? `+${mb} 倍率` : `×${Number(x.toFixed(2))} 倍率`, + cb, + mb, + x, + undefined, + j.uid, + ); + } + for (const c of e.scoring) + if (c.enh === 'glass' && !debuffed(run, c) && random(run, 'effect') < 0.25) { + run.cards = run.cards.filter((v) => v.id !== c.id); + add('玻璃破碎', '永久移除', 0, 0, 1, c.id); + } + return { + hand: e.hand, + name: e.name, + chips, + mult, + total: Math.floor(chips * mult), + trace, + played: structuredClone(played), + }; +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..466117c --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,82 @@ +export type Suit = 0 | 1 | 2 | 3; +export type Card = { + id: number; + rank: number; + suit: Suit; + enh?: 'bonus' | 'mult' | 'glass' | 'steel'; + seal?: boolean; +}; +export type Joker = { uid: number; id: string; value: number }; +export type Item = { + uid: number; + kind: 'joker' | 'planet' | 'tarot' | 'pack'; + ref: number; + price: number; +}; +export type Phase = 'blind' | 'play' | 'reward' | 'shop' | 'pack' | 'won' | 'lost'; +export type Trace = { + source: string; + label: string; + chips: number; + mult: number; + cardId?: number; + jokerUid?: number; +}; +export type Score = { + hand: number; + name: string; + chips: number; + mult: number; + total: number; + trace: Trace[]; + played: Card[]; +}; +export type Run = { + version: 1; + rules: 'velvet-v1'; + seed: string; + phase: Phase; + ante: number; + blind: number; + round: number; + money: number; + score: number; + target: number; + hands: number; + discards: number; + handSize: number; + cards: Card[]; + hand: number[]; + draw: number[]; + spent: number[]; + jokers: Joker[]; + consumables: Item[]; + levels: number[]; + counts: number[]; + bosses: number[]; + rng: { deck: number; shop: number; effect: number }; + nextId: number; + shop: Item[]; + packs: Item[]; + packChoices: Item[]; + reroll: number; + freeReroll: boolean; + investment: boolean; + skips: number; + last: Score | null; + reward: { base: number; hands: number; interest: number; bonus: number; total: number } | null; + best: number; + totalScore: number; + history: string[]; +}; +export type Command = + | { type: 'enter' } + | { type: 'skip' } + | { type: 'play' | 'discard'; ids: number[] } + | { type: 'cashout' } + | { type: 'next' } + | { type: 'reroll' } + | { type: 'buy' | 'sell' | 'take'; uid: number } + | { type: 'use'; uid: number; ids: number[] } + | { type: 'closePack' } + | { type: 'order'; zone: 'hand' | 'jokers'; ids: number[] }; diff --git a/src/platform/audio.ts b/src/platform/audio.ts new file mode 100644 index 0000000..b67a034 --- /dev/null +++ b/src/platform/audio.ts @@ -0,0 +1,37 @@ +import { readSetting, writeSetting } from './storage'; +let ctx: AudioContext | undefined; +export const sound = { + enabled: readSetting('velvet-sound') !== 'off', + play(kind: 'click' | 'card' | 'score' | 'win' = 'click', step = 0) { + if (!this.enabled) return; + try { + ctx ??= new AudioContext(); + void ctx.resume(); + const now = ctx.currentTime, + o = ctx.createOscillator(), + g = ctx.createGain(); + o.type = kind === 'score' ? 'triangle' : 'sine'; + const hz = + kind === 'win' + ? 660 + : kind === 'score' + ? 260 * Math.pow(1.065, Math.min(step, 28)) + : kind === 'card' + ? 360 + : 220; + o.frequency.setValueAtTime(hz, now); + o.frequency.exponentialRampToValueAtTime(hz * (kind === 'card' ? 0.6 : 1.4), now + 0.12); + g.gain.setValueAtTime(0.045, now); + g.gain.exponentialRampToValueAtTime(0.001, now + 0.18); + o.connect(g).connect(ctx.destination); + o.start(now); + o.stop(now + 0.2); + } catch { + /* Audio is optional. */ + } + }, + toggle() { + this.enabled = !this.enabled; + writeSetting('velvet-sound', this.enabled ? 'on' : 'off'); + }, +}; diff --git a/src/platform/storage.ts b/src/platform/storage.ts new file mode 100644 index 0000000..25d6c83 --- /dev/null +++ b/src/platform/storage.ts @@ -0,0 +1,177 @@ +import { JOKERS, HANDS, TAROTS } from '../content/catalog'; +import type { Run } from '../core/types'; +const KEY = 'velvet-joker-run-v1'; +export function readSetting(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} +export function writeSetting(key: string, value: string) { + try { + localStorage.setItem(key, value); + } catch { + /* Settings remain usable for this session. */ + } +} +export function validateRun(value: unknown): value is Run { + if (!value || typeof value !== 'object') return false; + const r = value as Run; + const ints = (a: unknown) => + Array.isArray(a) && a.every((n) => Number.isSafeInteger(n) && n >= 0); + if ( + r.version !== 1 || + r.rules !== 'velvet-v1' || + typeof r.seed !== 'string' || + !['blind', 'play', 'reward', 'shop', 'pack', 'won', 'lost'].includes(r.phase) + ) + return false; + if ( + ![ + r.ante, + r.blind, + r.round, + r.money, + r.hands, + r.discards, + r.handSize, + r.nextId, + r.reroll, + r.skips, + ].every(Number.isSafeInteger) || + r.ante < 1 || + r.ante > 8 || + r.blind < 0 || + r.blind > 2 || + r.money < 0 || + r.hands < 0 || + r.discards < 0 + ) + return false; + if (![r.score, r.target, r.best, r.totalScore].every((n) => Number.isFinite(n) && n >= 0)) + return false; + if ( + !ints(r.levels) || + r.levels.length !== HANDS.length || + r.levels.some((n) => n < 1) || + !ints(r.counts) || + r.counts.length !== 12 || + !ints(r.bosses) || + r.bosses.length !== 8 || + r.bosses.some((n) => n > 6) + ) + return false; + if ( + !r.rng || + !['deck', 'shop', 'effect'].every( + (k) => + Number.isSafeInteger(r.rng[k as keyof Run['rng']]) && + r.rng[k as keyof Run['rng']] > 0 && + r.rng[k as keyof Run['rng']] <= 4294967295, + ) + ) + return false; + if ( + !Array.isArray(r.cards) || + !r.cards.every( + (c) => + Number.isSafeInteger(c.id) && + c.rank >= 2 && + c.rank <= 14 && + Number.isInteger(c.rank) && + [0, 1, 2, 3].includes(c.suit) && + (!c.enh || ['bonus', 'mult', 'glass', 'steel'].includes(c.enh)), + ) + ) + return false; + const cardIds = r.cards.map((c) => c.id); + if (new Set(cardIds).size !== cardIds.length) return false; + if (![r.hand, r.draw, r.spent].every(ints)) return false; + const zones = [...r.hand, ...r.draw, ...r.spent]; + if (new Set(zones).size !== zones.length || zones.some((id) => !cardIds.includes(id))) + return false; + if ( + !Array.isArray(r.jokers) || + r.jokers.length > 5 || + !r.jokers.every( + (j) => + JOKERS.some((d) => d.id === j.id) && + Number.isSafeInteger(j.uid) && + Number.isFinite(j.value), + ) + ) + return false; + if ( + ![r.consumables, r.shop, r.packs, r.packChoices].every( + (a) => + Array.isArray(a) && + a.every( + (i) => + Number.isSafeInteger(i.uid) && + Number.isInteger(i.ref) && + i.ref >= 0 && + Number.isSafeInteger(i.price) && + i.price >= 0 && + ({ joker: JOKERS.length, planet: 12, tarot: TAROTS.length, pack: 2 }[i.kind] ?? 0) > + i.ref, + ), + ) + ) + return false; + if ( + r.consumables.length > 2 || + r.consumables.some((i) => i.kind !== 'planet' && i.kind !== 'tarot') || + !Array.isArray(r.history) || + !r.history.every((x) => typeof x === 'string') + ) + return false; + if ( + r.phase === 'reward' && + (!r.reward || + ![r.reward.base, r.reward.hands, r.reward.interest, r.reward.bonus, r.reward.total].every( + (n) => Number.isFinite(n) && n >= 0, + )) + ) + return false; + if ( + r.last && + (!Number.isFinite(r.last.total) || + !Number.isFinite(r.last.chips) || + !Number.isFinite(r.last.mult) || + typeof r.last.name !== 'string' || + !Array.isArray(r.last.trace) || + !Array.isArray(r.last.played)) + ) + return false; + const allIds = [ + ...cardIds, + ...r.jokers.map((j) => j.uid), + ...r.consumables.map((i) => i.uid), + ...r.shop.map((i) => i.uid), + ...r.packs.map((i) => i.uid), + ...r.packChoices.map((i) => i.uid), + ]; + if (new Set(allIds).size !== allIds.length || allIds.some((id) => id >= r.nextId)) return false; + return true; +} +export function load(): { run: Run | null; error?: string } { + try { + const data = localStorage.getItem(KEY); + if (!data) return { run: null }; + const r = JSON.parse(data); + return validateRun(r) + ? { run: r } + : { run: null, error: '存档版本不兼容或数据损坏。请新开一局。' }; + } catch { + return { run: null, error: '无法读取本地存档,请检查浏览器存储设置。' }; + } +} +export function save(run: Run) { + try { + localStorage.setItem(KEY, JSON.stringify(run)); + return true; + } catch { + return false; + } +} diff --git a/src/render/art.ts b/src/render/art.ts new file mode 100644 index 0000000..56b0679 --- /dev/null +++ b/src/render/art.ts @@ -0,0 +1,675 @@ +import { Texture } from 'pixi.js'; +import { JOKERS, HANDS, TAROTS } from '../content/catalog'; +import type { Card, Item } from '../core/types'; +const cache = new Map(); +const cream = '#eee5cb', + ink = '#263831'; +function surface(w = 180, h = 254) { + const canvas = document.createElement('canvas'); + canvas.width = w * 2; + canvas.height = h * 2; + const c = canvas.getContext('2d')!; + c.scale(2, 2); + return { canvas, c }; +} +function path(c: CanvasRenderingContext2D, pts: number[][], color: string) { + c.fillStyle = color; + c.beginPath(); + pts.forEach(([x, y], i) => (i ? c.lineTo(x, y) : c.moveTo(x, y))); + c.closePath(); + c.fill(); +} +function circle(c: CanvasRenderingContext2D, x: number, y: number, r: number, color: string) { + c.fillStyle = color; + c.beginPath(); + c.arc(x, y, r, 0, Math.PI * 2); + c.fill(); +} +function line( + c: CanvasRenderingContext2D, + x: number, + y: number, + x2: number, + y2: number, + color: string, + w = 1, +) { + c.strokeStyle = color; + c.lineWidth = w; + c.beginPath(); + c.moveTo(x, y); + c.lineTo(x2, y2); + c.stroke(); +} +function text( + c: CanvasRenderingContext2D, + s: string, + x: number, + y: number, + size = 16, + color = ink, + font = 'Georgia', + align: CanvasTextAlign = 'center', +) { + c.font = `bold ${size}px ${font}`; + c.fillStyle = color; + c.textAlign = align; + c.fillText(s, x, y); +} +function paper(c: CanvasRenderingContext2D, bg = cream) { + c.fillStyle = '#152d27'; + c.beginPath(); + c.roundRect(0, 0, 180, 254, 9); + c.fill(); + c.fillStyle = bg; + c.beginPath(); + c.roundRect(2, 2, 176, 250, 7); + c.fill(); + c.strokeStyle = '#b4af94'; + c.lineWidth = 0.8; + c.strokeRect(8, 8, 164, 238); + for (let i = 0; i < 1100; i++) { + const x = ((i * 97.37) % 176) + 2, + y = ((i * 41.63) % 250) + 2; + c.fillStyle = i % 2 ? '#00000009' : '#ffffff25'; + c.fillRect(x, y, 1, 1); + } +} +export function suit( + c: CanvasRenderingContext2D, + n: number, + x: number, + y: number, + s: number, + color?: string, +) { + c.save(); + c.translate(x, y); + c.scale(s / 20, s / 20); + const co = color || (n === 0 || n === 2 ? '#b8413d' : ink); + if (n === 2) + path( + c, + [ + [0, -12], + [9, 0], + [0, 12], + [-9, 0], + ], + co, + ); + if (n === 0) { + circle(c, -5, -4, 6.5, co); + circle(c, 5, -4, 6.5, co); + path( + c, + [ + [-11, -2], + [11, -2], + [0, 12], + ], + co, + ); + } + if (n === 1) { + path( + c, + [ + [0, -12], + [-10, 0], + [10, 0], + ], + co, + ); + circle(c, -4, 1, 6, co); + circle(c, 4, 1, 6, co); + path( + c, + [ + [0, 0], + [-4, 12], + [4, 12], + ], + co, + ); + } + if (n === 3) { + circle(c, 0, -6, 6, co); + circle(c, -6, 2, 6, co); + circle(c, 6, 2, 6, co); + path( + c, + [ + [0, 0], + [-4, 12], + [4, 12], + ], + co, + ); + } + c.restore(); +} +function jester(c: CanvasRenderingContext2D, variant: number, color: string) { + c.save(); + c.translate(90, 121); + // Printed diamond backdrop. + for (let x = -70; x < 70; x += 20) + for (let y = -80; y < 95; y += 24) + path( + c, + [ + [x, y - 9], + [x + 7, y], + [x, y + 9], + [x - 7, y], + ], + '#ffffff20', + ); + circle(c, 0, -5, 58, '#f1dcac'); + circle(c, 0, -5, 51, color); + circle(c, 0, -5, 47, '#293e37'); + path( + c, + [ + [-57, 93], + [-48, 47], + [-27, 25], + [0, 38], + [27, 25], + [48, 47], + [57, 93], + ], + cream, + ); + path( + c, + [ + [-45, 90], + [-37, 48], + [-15, 33], + [0, 55], + [0, 90], + ], + color, + ); + path( + c, + [ + [0, 55], + [15, 33], + [37, 48], + [45, 90], + [0, 90], + ], + '#344e47', + ); + for (let y = 60; y < 96; y += 20) { + path( + c, + [ + [-24, y - 6], + [-17, y], + [-24, y + 6], + [-31, y], + ], + '#344e47', + ); + path( + c, + [ + [24, y - 6], + [31, y], + [24, y + 6], + [17, y], + ], + color, + ); + } + path( + c, + [ + [-30, -15], + [-32, 9], + [-16, 34], + [0, 40], + [16, 34], + [32, 9], + [30, -15], + ], + cream, + ); + path( + c, + [ + [0, -13], + [26, -13], + [22, 9], + [8, 5], + [0, 14], + ], + '#d2c398', + ); + // Three-point jester cap, pixel stepped silhouette. + path( + c, + [ + [-34, -8], + [-46, -19], + [-52, -41], + [-36, -36], + [-23, -22], + [-15, -56], + [0, -77], + [14, -58], + [22, -25], + [39, -40], + [53, -44], + [47, -18], + [32, -8], + [17, -17], + [0, -21], + [-18, -16], + ], + color, + ); + path( + c, + [ + [0, -77], + [14, -58], + [22, -25], + [39, -40], + [53, -44], + [47, -18], + [32, -8], + [17, -17], + [0, -21], + ], + '#263c36', + ); + [ + [-52, -42], + [0, -78], + [54, -44], + ].forEach(([x, y]) => { + circle(c, x, y, 6, '#d7b25f'); + circle(c, x - 1, y - 1, 2, cream); + }); + c.fillStyle = ink; + c.fillRect(-21, -2, 12, 5); + c.fillRect(9, -2, 12, 5); + c.fillRect(-17, 3, 4, 6); + c.fillRect(13, 3, 4, 6); + if (variant % 4 === 1) { + c.strokeStyle = color; + c.lineWidth = 4; + c.strokeRect(-24, -7, 19, 15); + c.strokeRect(5, -7, 19, 15); + line(c, -5, 0, 5, 0, color, 3); + } + if (variant % 4 === 2) + path( + c, + [ + [9, -7], + [24, -7], + [21, 8], + [12, 8], + ], + color, + ); + path( + c, + [ + [0, 0], + [-5, 14], + [5, 14], + ], + '#b65e4b', + ); + path( + c, + [ + [-15, 20], + [0, 24], + [15, 20], + [8, 30], + [-7, 30], + ], + '#a74239', + ); + line(c, -10, 23, 10, 23, cream, 3); + path( + c, + [ + [-30, 28], + [-41, 38], + [-20, 54], + [0, 41], + [20, 54], + [41, 38], + [30, 28], + [15, 40], + [0, 35], + [-15, 40], + ], + '#f5e9cc', + ); + for (let y = 57; y < 90; y += 14) circle(c, 0, y, 3, '#d7b25f'); + if (variant % 4 === 3) { + line(c, -45, 81, 51, 37, '#d8b76d', 5); + suit(c, variant % 4, 50, 28, 23, '#d8b76d'); + } + c.restore(); +} +function court(c: CanvasRenderingContext2D, card: Card) { + c.save(); + c.beginPath(); + c.rect(32, 34, 116, 186); + c.clip(); + c.fillStyle = '#dfcfa6'; + c.fillRect(32, 34, 116, 186); + const co = card.suit % 2 === 0 ? '#b54740' : '#31555e'; + for (let i = 0; i < 2; i++) { + c.save(); + if (i) { + c.translate(180, 254); + c.rotate(Math.PI); + } + path( + c, + [ + [35, 128], + [47, 85], + [75, 68], + [108, 75], + [142, 111], + [144, 130], + ], + co, + ); + for (let x = 42; x < 143; x += 12) { + line(c, x, 104, x + 10, 126, '#c3a457', 3); + } + c.fillStyle = ink; + c.fillRect(65, 48, 48, 42); + c.fillStyle = cream; + c.fillRect(75, 48, 31, 42); + path( + c, + [ + [106, 64], + [115, 73], + [105, 75], + ], + cream, + ); + c.fillStyle = ink; + c.fillRect(99, 61, 4, 4); + line(c, 96, 80, 105, 80, co, 2); + path( + c, + [ + [64, 50], + [62, 33], + [73, 39], + [80, 27], + [89, 39], + [100, 27], + [106, 39], + [117, 33], + [112, 50], + ], + '#b99a4e', + ); + c.strokeStyle = ink; + c.strokeRect(65, 48, 48, 4); + suit(c, card.suit, 54, 65, 21); + line(c, 128, 58, 128, 129, cream, 5); + path( + c, + [ + [128, 50], + [120, 65], + [136, 65], + ], + '#c3a457', + ); + c.restore(); + } + c.restore(); + c.strokeStyle = ink; + c.strokeRect(32, 34, 116, 186); +} +export function cardTexture(card: Card): Texture { + const key = `c${card.rank}-${card.suit}-${card.enh}-${card.seal}`; + if (cache.has(key)) return cache.get(key)!; + const { canvas, c } = surface(); + paper(c, card.enh === 'steel' ? '#ccd7d3' : card.enh === 'glass' ? '#e2edef' : cream); + const r = + ({ 11: 'J', 12: 'Q', 13: 'K', 14: 'A' } as Record)[card.rank] || + String(card.rank), + color = card.suit % 2 === 0 ? '#b8413d' : ink; + for (let i = 0; i < 2; i++) { + c.save(); + if (i) { + c.translate(180, 254); + c.rotate(Math.PI); + } + text(c, r, 20, 31, 25, color); + suit(c, card.suit, 20, 49, 17); + c.restore(); + } + if (card.rank >= 11 && card.rank <= 13) court(c, card); + else if (card.rank === 14) { + suit(c, card.suit, 90, 125, 68); + text(c, 'VELVET', 90, 194, 9, color, 'Courier New'); + } else { + const pos: Record = { + 2: [ + [90, 62], + [90, 191], + ], + 3: [ + [90, 62], + [90, 127], + [90, 191], + ], + 4: [ + [57, 62], + [123, 62], + [57, 191], + [123, 191], + ], + 5: [ + [57, 62], + [123, 62], + [90, 127], + [57, 191], + [123, 191], + ], + 6: [ + [57, 62], + [123, 62], + [57, 127], + [123, 127], + [57, 191], + [123, 191], + ], + 7: [ + [57, 62], + [123, 62], + [90, 94], + [57, 127], + [123, 127], + [57, 191], + [123, 191], + ], + 8: [ + [57, 62], + [123, 62], + [90, 94], + [57, 127], + [123, 127], + [90, 160], + [57, 191], + [123, 191], + ], + 9: [ + [57, 56], + [123, 56], + [57, 102], + [123, 102], + [90, 126], + [57, 151], + [123, 151], + [57, 197], + [123, 197], + ], + 10: [ + [57, 56], + [123, 56], + [90, 79], + [57, 102], + [123, 102], + [57, 151], + [123, 151], + [90, 174], + [57, 197], + [123, 197], + ], + }; + pos[card.rank].forEach(([x, y]) => { + c.save(); + c.translate(x, y); + if (y > 130) c.rotate(Math.PI); + suit(c, card.suit, 0, 0, 25); + c.restore(); + }); + } + if (card.enh) { + c.strokeStyle = card.enh === 'glass' ? '#81b8c5' : '#a88b41'; + c.lineWidth = 3; + c.strokeRect(5, 5, 170, 244); + text(c, { bonus: '+30', mult: '+4', glass: '×2', steel: '×1.5' }[card.enh], 90, 241, 10); + } + if (card.seal) { + circle(c, 141, 213, 11, '#ad4139'); + circle(c, 141, 213, 7, '#cb5c48'); + text(c, '↻', 141, 218, 15, cream); + } + const t = Texture.from(canvas); + cache.set(key, t); + return t; +} +export function jokerTexture(index: number): Texture { + const key = `j${index}`; + if (cache.has(key)) return cache.get(key)!; + const j = JOKERS[index], + { canvas, c } = surface(); + paper(c); + c.fillStyle = j.color; + c.fillRect(13, 13, 154, 210); + jester(c, j.motif, j.color); + for (let n = 0; n < 5; n++) text(c, 'JOKER'[n], 23, 33 + n * 14, 12, cream, 'Courier New'); + text(c, j.en, 90, 239, 10, ink, 'Courier New'); + const t = Texture.from(canvas); + cache.set(key, t); + return t; +} +export function itemTexture(i: Item): Texture { + if (i.kind === 'joker') return jokerTexture(i.ref); + const key = `${i.kind}${i.ref}`; + if (cache.has(key)) return cache.get(key)!; + const { canvas, c } = surface(); + paper(c); + c.fillStyle = i.kind === 'planet' ? '#334857' : i.kind === 'pack' ? '#86703e' : '#69526b'; + c.fillRect(13, 13, 154, 211); + for (let n = 0; n < 45; n++) { + const x = 23 + ((n * 39.7) % 133), + y = 22 + ((n * 59.3) % 190); + circle(c, x, y, n % 5 === 0 ? 1.5 : 0.6, '#dfd1ad'); + } + if (i.kind === 'planet') { + const color = [ + '#b4aaa0', + '#c5a48a', + '#83b2b0', + '#cdae74', + '#b7b87b', + '#b7916f', + '#83ad9a', + '#ba7d64', + '#82a3b1', + ][i.ref % 9]; + circle(c, 90, 117, 45, color); + c.save(); + c.beginPath(); + c.arc(90, 117, 45, 0, Math.PI * 2); + c.clip(); + for (let y = 80; y < 160; y += 9) line(c, 40, y, 140, y + 8, '#ffffff18', 4); + circle(c, 111, 113, 42, '#162e3444'); + c.restore(); + c.strokeStyle = cream; + c.lineWidth = 2; + c.beginPath(); + c.ellipse(90, 117, 68, 17, -0.35, 0, Math.PI * 2); + c.stroke(); + text(c, HANDS[i.ref][5], 90, 204, 16, cream, 'serif'); + text(c, 'CELESTIAL', 90, 240, 10, ink, 'Courier New'); + } else { + c.strokeStyle = '#d2b673'; + c.lineWidth = 1.5; + c.strokeRect(22, 24, 136, 188); + for (let n = 0; n < 12; n++) { + const a = (n * Math.PI) / 6; + line( + c, + 90 + Math.cos(a) * 41, + 116 + Math.sin(a) * 41, + 90 + Math.cos(a) * 57, + 116 + Math.sin(a) * 57, + '#d2b673', + 2, + ); + } + circle(c, 90, 116, 35, '#dfce9e'); + circle(c, 90, 116, 29, '#69526b'); + suit(c, i.ref % 4, 90, 115, 32, '#dfce9e'); + text(c, i.kind === 'pack' ? '精选补充包' : TAROTS[i.ref].name, 90, 198, 16, cream, 'serif'); + text(c, i.kind === 'pack' ? 'THE COLLECTION' : 'ARCANA', 90, 240, 10, ink, 'Courier New'); + } + const t = Texture.from(canvas); + cache.set(key, t); + return t; +} +export function backTexture(): Texture { + if (cache.has('back')) return cache.get('back')!; + const { canvas, c } = surface(); + paper(c); + c.fillStyle = '#a64d3e'; + c.fillRect(13, 13, 154, 228); + for (let x = 20; x < 165; x += 12) + for (let y = 20; y < 236; y += 12) + path( + c, + [ + [x, y - 4], + [x + 4, y], + [x, y + 4], + [x - 4, y], + ], + '#e6c78d88', + ); + c.strokeStyle = cream; + c.lineWidth = 2; + c.strokeRect(19, 19, 142, 216); + c.strokeRect(23, 23, 134, 208); + circle(c, 90, 127, 46, '#a64d3e'); + circle(c, 90, 127, 40, cream); + circle(c, 90, 127, 36, '#a64d3e'); + suit(c, 1, 90, 126, 43, cream); + const t = Texture.from(canvas); + cache.set('back', t); + return t; +} diff --git a/src/render/board.ts b/src/render/board.ts new file mode 100644 index 0000000..d9d7254 --- /dev/null +++ b/src/render/board.ts @@ -0,0 +1,345 @@ +import { Application, Container, Graphics, Sprite, Text, Rectangle } from 'pixi.js'; +import { JOKERS } from '../content/catalog'; +import { cardTexture, jokerTexture, itemTexture, backTexture } from './art'; +import { debuffed } from '../core/scoring'; +import type { Run, Card, Item } from '../core/types'; +type Callbacks = { + select: (id: number) => void; + inspect: (kind: 'joker' | 'item' | 'card', id: number) => void; + hover: (title: string, body: string, x: number, y: number) => void; + unhover: () => void; + reorder: (zone: 'hand' | 'jokers', from: number, to: number) => void; +}; +export class Board { + app = new Application(); + layer = new Container(); + glow = new Container(); + nodes = new Map(); + time = 0; + longPress: ReturnType | undefined; + drag: { + zone: 'hand' | 'jokers'; + id: number; + x: number; + y: number; + node: Container; + startX: number; + startY: number; + } | null = null; + async init(host: HTMLElement, publicCallbacks: Callbacks) { + this.callbacks = publicCallbacks; + await this.app.init({ + width: 1440, + height: 900, + backgroundAlpha: 0, + antialias: true, + resolution: Math.min(window.devicePixelRatio || 1, 2), + autoDensity: true, + preference: 'webgl', + }); + host.append(this.app.canvas); + this.app.canvas.setAttribute('aria-label', '可交互的纸牌桌面'); + this.app.stage.addChild(this.layer, this.glow); + this.app.stage.eventMode = 'static'; + this.app.stage.hitArea = new Rectangle(0, 0, 1440, 900); + this.app.stage.on('globalpointermove', (e) => { + if (this.drag) { + const d = this.drag; + if (Math.hypot(e.global.x - d.x, e.global.y - d.y) > 8) clearTimeout(this.longPress); + d.node.x = d.startX + e.global.x - d.x; + d.node.y = d.startY + e.global.y - d.y; + d.node.rotation = 0; + } + }); + this.app.stage.on('pointerup', (e) => this.finishDrag(e.global.x)); + this.app.stage.on('pointerupoutside', (e) => this.finishDrag(e.global.x)); + this.app.canvas.addEventListener('pointercancel', () => { + clearTimeout(this.longPress); + this.drag = null; + this.callbacks.unhover(); + if (this.run) this.draw(this.run, this.selection, this.busy); + }); + this.app.canvas.addEventListener('pointermove', (e) => { + if (!this.drag) return; + const rect = this.app.canvas.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 1440; + const y = ((e.clientY - rect.top) / rect.height) * 900; + if (Math.hypot(x - this.drag.x, y - this.drag.y) > 8) clearTimeout(this.longPress); + }); + this.app.ticker.add((t) => { + this.time += t.deltaMS; + this.layer.children.forEach((n) => { + const a = n as Container & { baseY?: number; float?: number }; + if (a.baseY !== undefined && a !== this.drag?.node) + a.y = a.baseY + Math.sin(this.time * 0.0015 + (a.float || 0)) * 1.2; + }); + }); + } + callbacks!: Callbacks; + run!: Run; + selection: number[] = []; + busy = false; + label( + s: string, + x: number, + y: number, + size = 12, + color = '#c6c9b6', + align: 'left' | 'center' = 'left', + ) { + const t = new Text({ + text: s, + style: { + fontFamily: 'Courier New, serif', + fontSize: size, + fill: color, + letterSpacing: 2, + fontWeight: 'bold', + align, + }, + }); + t.position.set(x, y); + if (align === 'center') t.anchor.x = 0.5; + this.layer.addChild(t); + return t; + } + private finishDrag(x: number) { + clearTimeout(this.longPress); + const d = this.drag; + if (!d) return; + this.drag = null; + const moved = Math.abs(x - d.x) > 18 || Math.abs(d.node.y - d.startY) > 18; + if (moved) { + const ids = d.zone === 'hand' ? this.run.hand : this.run.jokers.map((j) => j.uid); + const centers = + d.zone === 'hand' + ? ids.map((_, i) => this.handX(i, ids.length)) + : ids.map((_, i) => 421 + i * 146); + let near = 0; + centers.forEach((cx, i) => { + if (Math.abs(cx - x) < Math.abs(centers[near] - x)) near = i; + }); + this.callbacks.reorder(d.zone, d.id, ids[near]); + } else if (d.zone === 'hand') this.callbacks.select(d.id); + else this.callbacks.inspect('joker', d.id); + if (this.run) this.draw(this.run, this.selection, this.busy); + } + handX(i: number, n: number) { + return 820 + (i - (n - 1) / 2) * Math.min(99, 790 / Math.max(1, n - 1)); + } + private card( + tex: ReturnType, + x: number, + y: number, + w: number, + h: number, + rot = 0, + ) { + const n = new Container(); + n.position.set(x, y); + n.rotation = rot; + const shadow = new Graphics() + .roundRect(-w / 2 + 4, -h / 2 + 9, w, h, 7) + .fill({ color: 0x071c17, alpha: 0.55 }); + n.addChild(shadow); + const s = new Sprite(tex); + s.anchor.set(0.5); + s.width = w; + s.height = h; + n.addChild(s); + this.layer.addChild(n); + return n; + } + draw(r: Run, selected: number[] = [], busy = false) { + this.run = r; + this.selection = selected; + this.busy = busy; + if (this.drag) return; + for (const child of this.layer.removeChildren()) child.destroy({ children: true }); + this.nodes.clear(); + const ornaments = new Graphics(); + ornaments.roundRect(331, 111, 1077, 721, 22).stroke({ width: 1, color: 0xd0b67c, alpha: 0.16 }); + ornaments.roundRect(342, 122, 1055, 699, 17).stroke({ width: 1, color: 0xd0b67c, alpha: 0.08 }); + ornaments.moveTo(363, 333).lineTo(1375, 333).stroke({ width: 1, color: 0xd1bf8b, alpha: 0.17 }); + this.layer.addChild(ornaments); + this.label('JOKERS', 369, 132, 11, '#adbaa8'); + this.label(`${r.jokers.length} / 5`, 1062, 132, 11, '#adbaa8'); + this.label('CONSUMABLES', 1190, 132, 10, '#adbaa8'); + for (let i = 0; i < 5; i++) { + const j = r.jokers[i], + x = 421 + i * 146; + if (!j) { + const g = new Graphics() + .roundRect(x - 56, 168, 112, 144, 7) + .stroke({ width: 1, color: 0xd8c69b, alpha: 0.18 }); + this.layer.addChild(g); + this.label('✦', x, 228, 24, '#597568', 'center'); + continue; + } + const idx = JOKERS.findIndex((d) => d.id === j.id), + def = JOKERS[idx], + n = this.card(jokerTexture(idx), x, 241, 108, 153, (i - 2) * 0.018); + this.nodes.set('j' + j.uid, n); + Object.assign(n, { baseY: 241, float: i }); + n.eventMode = busy ? 'none' : 'static'; + n.cursor = 'grab'; + n.on('pointerover', () => { + this.callbacks.hover(def.name, def.desc, x, 326); + n.scale.set(1.045); + }); + n.on('pointerout', () => { + this.callbacks.unhover(); + n.scale.set(1); + }); + n.on('pointerdown', (e) => { + this.callbacks.unhover(); + this.drag = { + zone: 'jokers', + id: j.uid, + x: e.global.x, + y: e.global.y, + node: n, + startX: n.x, + startY: n.y, + }; + this.layer.setChildIndex(n, this.layer.children.length - 1); + }); + } + for (let i = 0; i < 2; i++) { + const it = r.consumables[i], + x = 1232 + i * 92; + if (it) { + const n = this.card(itemTexture(it), x, 228, 77, 109, (i ? 1 : -1) * 0.035); + n.eventMode = busy ? 'none' : 'static'; + n.cursor = 'pointer'; + n.on('pointertap', () => this.callbacks.inspect('item', it.uid)); + } else { + this.layer.addChild( + new Graphics() + .roundRect(x - 36, 174, 72, 107, 5) + .stroke({ width: 1, color: 0xcac49e, alpha: 0.2 }), + ); + this.label('+', x, 214, 22, '#688375', 'center'); + } + } + if (['play', 'reward', 'lost', 'won'].includes(r.phase)) { + for (let i = 0; i < 3; i++) + this.card(backTexture(), 1336 + i * 2, 690 - i * 3, 92, 130, 0.065); + this.label(`${r.draw.length} / ${r.cards.length}`, 1340, 779, 12, '#e5d9b8', 'center'); + this.label('牌 库', 1340, 804, 10, '#91a694', 'center'); + r.hand.forEach((id, i) => { + const c = r.cards.find((c) => c.id === id)!; + this.handCard(c, i, r.hand.length, selected.includes(id), busy); + }); + this.label(`${selected.length} 已选 / 最多 5 张`, 820, 785, 11, '#a7baaa', 'center'); + } + if (r.phase === 'shop' || r.phase === 'pack') { + const items = r.phase === 'shop' ? r.shop : r.packChoices; + items.forEach((it, i) => { + const n = this.card(itemTexture(it), 510 + i * 210, 506, 120, 169, (i - 1) * 0.025); + n.eventMode = 'static'; + n.cursor = 'pointer'; + n.on('pointertap', () => this.callbacks.inspect('item', it.uid)); + }); + if (r.phase === 'shop') + r.packs.forEach((it, i) => { + const n = this.card(itemTexture(it), 1150 + i * 141, 510, 98, 139, (i ? 1 : -1) * 0.04); + n.eventMode = 'static'; + n.cursor = 'pointer'; + n.on('pointertap', () => this.callbacks.inspect('item', it.uid)); + }); + } + // Commit world transforms before exposing the newly rebuilt interactive scene. + this.app.render(); + } + private handCard(c: Card, i: number, count: number, selected: boolean, busy: boolean) { + const x = this.handX(i, count), + y = 664 + Math.abs(i - (count - 1) / 2) * 2 - (selected ? 30 : 0), + rot = (i - (count - 1) / 2) * 0.018; + const n = this.card(cardTexture(c), x, y, 121, 171, rot); + this.nodes.set('c' + c.id, n); + if (debuffed(this.run, c)) { + n.alpha = 0.5; + } + if (selected) + n.addChild( + new Graphics() + .roundRect(-62, -87, 124, 174, 7) + .stroke({ width: 2, color: 0xe3c77e, alpha: 0.95 }), + ); + n.eventMode = busy || this.run.phase !== 'play' ? 'none' : 'static'; + n.cursor = 'grab'; + n.on('pointerover', () => { + if (!this.drag) { + n.y -= 12; + n.scale.set(1.025); + } + const note = c.enh + ? { + bonus: '+30 筹码', + mult: '+4 倍率', + glass: '计分 ×2 倍率,1/4 概率破碎', + steel: '留手 ×1.5 倍率', + }[c.enh] + : ''; + if (note || c.seal) + this.callbacks.hover('强化牌', note + (c.seal ? ' · 红封:再次触发' : ''), x, 510); + }); + n.on('pointerout', () => { + if (!this.drag) n.y = y; + n.scale.set(1); + this.callbacks.unhover(); + }); + n.on('pointerdown', (e) => { + if (e.button !== 0) return; + this.callbacks.unhover(); + this.drag = { + zone: 'hand', + id: c.id, + x: e.global.x, + y: e.global.y, + node: n, + startX: n.x, + startY: n.y, + }; + this.layer.setChildIndex(n, this.layer.children.length - 1); + if (e.pointerType === 'touch') + this.longPress = setTimeout(() => { + this.drag = null; + this.callbacks.inspect('card', c.id); + this.draw(this.run, this.selection, this.busy); + }, 550); + }); + n.on('rightclick', () => this.callbacks.inspect('card', c.id)); + } + showPlayed(cards: Card[]) { + cards.forEach((c, i) => { + const n = this.card( + cardTexture(c), + 820 + (i - (cards.length - 1) / 2) * 91, + 453, + 77, + 108, + (i - (cards.length - 1) / 2) * 0.025, + ); + this.nodes.set('p' + c.id, n); + }); + } + highlight(cardId?: number, jokerUid?: number) { + for (const c of this.glow.removeChildren()) c.destroy(); + const n = jokerUid + ? this.nodes.get('j' + jokerUid) + : this.nodes.get('p' + cardId) || this.nodes.get('c' + cardId); + if (!n) return; + const g = new Graphics() + .roundRect(-59, -83, 118, 166, 7) + .stroke({ width: 3, color: 0xf4d68d, alpha: 0.85 }); + g.position.copyFrom(n.position); + g.rotation = n.rotation; + if (this.nodes.get('p' + cardId) === n) g.scale.set(0.72); + this.glow.addChild(g); + } + clearHighlight() { + for (const c of this.glow.removeChildren()) c.destroy(); + } +} diff --git a/tests/balance.test.ts b/tests/balance.test.ts new file mode 100644 index 0000000..97cacb4 --- /dev/null +++ b/tests/balance.test.ts @@ -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 = { + 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); diff --git a/tests/browser.mjs b/tests/browser.mjs new file mode 100644 index 0000000..0805a00 --- /dev/null +++ b/tests/browser.mjs @@ -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(); diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..1f4f97f --- /dev/null +++ b/tests/core.test.ts @@ -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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b53090b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true + }, + "include": ["src", "tests"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..33dd31f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'vite'; +export default defineConfig({ + build: { target: 'es2022', rollupOptions: { output: { manualChunks: { pixi: ['pixi.js'] } } } }, +});