纯文字、纯单机、模组驱动、确定性的 Roguelike 自动战斗工具。
你不操作战斗。你做的事是编排技能序列(GCD 循环队列 + oGCD 优先级槽),让角色自己打, 观察结果,回去改序列。战斗过程是纯函数:
(种子, 序列配置, 内容模组) → 逐帧结果必然一致
1x / 4x / MAX 三种倍速下的终态逐字节相同,种子是复现整局的完整凭据 —— 抄下种子就能在别的机器上重放出同一局。这是本项目的立项卖点,也是全部工程约束的来源。
npm install
npm run dev # 本地开发(默认 http://localhost:5173)
npm test # vitest run
npm run lint # eslint
npm run build # vite build
npm run verify # lint + test + build 串联(提交前的门)没有后端、没有构建产物依赖、没有账号:npm run build 出来的 dist/ 是纯静态站,
丢到任意静态托管即可玩。
仓库里已带了 .github/workflows/deploy.yml:push 到 master 就自动构建并发布。
第一次需要做三件事(都是仓库设置,不是代码):
# 1) 在 GitHub 上建一个空仓库(不要初始化 README,会和本地冲突),然后:
git remote add origin https://github.com/<你的用户名>/<仓库名>.git
git push -u origin master3) 到仓库 Settings → Pages → Build and deployment → Source: GitHub Actions。 不开关这一步,workflow 会显示跑成功但站点不更新 —— 这是 Pages 最常见的坑。
站点地址:项目页是 https://<用户名>.github.io/<仓库名>/。
vite.config.js 里写了 base: './'。不写的话产物引用 /assets/xxx.js,
放到子路径就是白屏 + 控制台一堆 404,而 vite build 不会报任何错。
音频目录同样跟着 base 走(src/ui/audio/soundMap.js)。
本地就能验,不需要先推上去:
npm run deploy:check # 把 dist 挂到 /fate_loop/ 子路径下起服务,逐个请求资源它会在 CI 里作为发布前的一道闸门:base 配错、某处写死了 /assets、
或者沙箱用的 wasm 没进产物,都会在这里红掉而不是变成线上白屏。
- 主菜单点 新的轮回,输入任意种子(数字或一串词语都行,留空则随机)
- 在地图屏点相邻节点移动;踩到战斗节点自动开战
- 战斗屏只能看 —— 用 1x / 4x / MAX 控制观察速度,MAX 直接同步跑到结束
- 打输了?回去在技能轴屏换序列。装备屏换装、强化、分解
- 走到出口下一层,休息回血、商店买永久加成、事件做取舍
- 死了就清零(局内 run)。战绩屏能看到历次轮回的种子与到达层数
src/
boot.js 浏览器入口:装配 + 失败兜底错误屏
main.js 应用装配工厂 createApp():内核 → 屏幕 → 意图翻译
core/ 纯逻辑,禁止碰 DOM(eslint 硬禁 async / Math.random / Date.now)
constants.js 枚举与调参常量的单一来源
prng.js Mulberry32 + 三流分派(map / battle / player)
store.js 不可变状态容器(update(draft) + 订阅 + getSnapshot 深冻结)
derived.js 属性单一数据源:recalcPlayer / addPermanentBonus
game.js GameFlow:探索流程编排(移动 / 下层 / 休息 / 商店 / 事件 / 装备)
battle/ engine(主循环+倍速)/ scheduler(oGCD 抢占)/ resolution(伤害)
contracts/ 契约注册表 —— 模组只能通过契约影响世界
mods/ 模组加载六步法(发现 → 依赖 → 检环 → 拓扑 → 实例化 → 覆盖裁决)
mods/official/ 官方内容模组:90 技能 · 14 Buff · 300 怪物 · 100 遭遇 · 商品 · 事件 · 地图
persistence/ SaveService(同步入队 + 宏任务串行 flush)+ schema + 三适配器降级
ui/ shell 外壳 / router 屏幕路由 / dialog 模态 / screens/ 10 屏 / map SVG
依赖方向是单向的:ui → core、ui → persistence,core 永不 import ui,
core/** 也永不直接调存储(存储异步,而战斗必须同步)。
- 三流随机分派 —— 地图 / 战斗 / 玩家开局各自的流互不干扰;战斗流的调用次数必须
与倍速无关;UI 要随机数请用
Math.random(),别碰 PRNG - 绝对到期时间戳 —— 冷却与 Buff 存
readyAt/expiresAt,只比较大小,绝不每帧递减 - 时间量必须是
STEP_MS(16ms) 整数倍 —— 模组加载时normalize会强制拒绝 - 属性单一数据源 ——
maxHp/attack/defense/critChance一律由(exp, equipment, seedBonus, permanentBonus)重算;「永久提升」必须走addPermanentBonus,直接写派生值等于写空气(有守卫测试拦) - 音频永不影响逻辑 —— 契约返回
undefined、不消费随机数、sink 抛错不外传 - UI 不得触碰活状态 —— 屏幕只吃
getSnapshot(),要改状态就调GameFlow方法
细节与反例见 docs/交接文档.md。
| 文件 | 守什么 |
|---|---|
integration/cross-speed.test.js |
1x / 4x / MAX 终态逐项相等(改引擎必看它绿) |
integration/app-wiring.test.js |
阶段 9 接线的 jsdom 冒烟:十屏、节点操作、结算、存档、路由、渲染再多也不消费随机流 |
integration/screens.test.js |
屏幕级细测:序列编辑、装备操作、排序筛选、无障碍与焦点陷阱 |
integration/balance.test.js |
开局可用性:1 级两类手段都有、默认序列能打下 1~3 层(胜率固化成确定性断言) |
unit/gameflow.test.js |
探索流程编排:移动 / 战斗结算 / 商店 / 事件 / 下层 / 装备 / 读档 |
unit/scheduler.test.js |
oGCD 抢占与平局打破(裁决 3)、目标解析、GCD 循环队列 |
unit/determinism-core.test.js、unit/progression.test.js |
PRNG 分派、经验曲线、解锁表 |
unit/equipment.test.js |
装备生成纯函数、派生属性、强化/分解、永久加成 |
unit/map-generation.test.js |
网格地图生成不变量:连通、起点出口、死路、300 组种子 |
unit/persistence.test.js |
三适配器同形、序列化纯函数、版本拒读、成长字段往返 |
unit/mod-loading.test.js |
六步法与 normalize、内容守卫(禁止手写派生值) |
unit/audio.test.js、unit/contracts.test.js |
节流器、清单、契约注册与音频无关性 |
jsdom 没有布局引擎,CSS 写歪了测试照样全绿。所以仓库里带了一个真浏览器体检:
npm run ui:audit # 13 个界面 × 1440x900
npm run ui:audit -- --views=1440x900,900x900,420x900 # 多档视口矩阵
npm run ui:audit -- --only=map,battle --shots=.ui-audit
UI_AUDIT_BROWSER=/路径/msedge.exe npm run ui:audit # 指定浏览器它起一个 dev server,用无头 Edge/Chrome 把应用驱动到每个界面(打过仗、有装备、 开着商店/事件/通关面板),然后量:横向溢出、零高度可见元素、元素跑出视口、 文本被裁且没写 ellipsis、面板被遮挡、对比度 < 4.5:1、当前屏几乎是空的, 外加屏高占比 / 节点数 / 雾点数 / 可达节点 / 实体卡数等指标。有问题就非零退出。
两条设计约束值得记:
- 每个用例都有前置断言("通关面板确实打开了")。没有它,"其实阵亡在地图上" 这种状态会安静地报成 ✓ —— 比没有体检更坏
- 结果写进
<script type="application/json">由 Node 侧解析,不引入 puppeteer/playwright(本项目运行时依赖只有 howler)
内容就是放在 src/mods/<作者>/<模组名>/ 下的 ES 模块:一个 manifest.js 声明
id / version / provides / requires,一个 setup.js 往内容池里注册技能、怪物、
遭遇、商品、事件或地图生成器。加载器按依赖拓扑排序实例化,任一模组出错则整体
加载失败(部分加载会留下悬空 ID 引用,比直接失败难查得多)。
想写模组:看 docs/模组开发指南.md —— 契约签名、八种产物的
字段速查、六条硬约束、覆盖规则、常见报错对照表。
直接抄最省事的起点是仓库里那个会被真正加载的示例模组:
src/mods/dev/example-pack/ —— 它注册了一个新流派
(虚空)、5 个技能、1 个 Buff、2 种怪物、3 个遭遇、2 件商品、1 个事件,
并且由 tests/integration/mod-example.test.js 端到端验证,所以它不会悄悄过期。
阶段 0~9 已完成并接线(多屏 UI 可用),一局可以打通:
- 终点是第 50 层 —— 使用第 50 层的出口即通关,弹结算面板并写入历史战绩
(
outcome: 'victory')。之后可以选「继续挑战无尽」,那之后没有第二次结算, 死在无尽里的记录会带「通关后 · 无尽」标记 - 50 这个数字来自实测:按现有经验曲线,50 层大约 48 级,是"一局走得完又足够长"
的位置。经验曲线与
SKILL_UNLOCK_MAX_LEVEL = 120仍不匹配(详见docs/交接文档.md附录),那是下一轮要谈的平衡问题
已知限制:
- 仓库不含音频资源 ——
AUDIO_ASSETS为空,音频层整体静默降级,不发请求 - 宝珠系统未实装 ——
RARITIES[].orbSlots只是数据,图鉴会如实标注 - 官方遭遇模板最深到第 999 层;再深的无尽段会回退到最深一档模板(不会崩,但 数值不再增长,等于难度天花板)
docs/《命运轮回:FATE LOOP》完整设计计划书.md—— 主设计(玩法、状态模型、规格)docs/技术实施细化方案.md—— 分层、契约、裁决与验收门docs/交接文档.md—— 接手必读:现状、不变量、未完成工作、推进路线、实测数据docs/archive/—— 两份属于旧项目(Boss Rush 弹幕)的文档,仅作历史参考